Skip to content

Commit 126dab1

Browse files
OgeonX-AiAitomatesclaude
authored
ci: harden workflows and registry compatibility (#14)
* ci: add dependabot github-actions updates Add a github-actions package ecosystem entry so third-party actions pinned with mutable tags (e.g. @v4) get auto-updated weekly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci: harden workflow permissions and timeouts * test(contracts): verify published registry drift * docs(build): correct project verification commands * fix: normalize contract digest comparisons * fix(contracts): pin canonical v1.1.1 release * fix(contracts): canonicalize release bytes as LF --------- Co-authored-by: Kim Harjamäki <kim.harjamaki@prosimo.fi> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c6d524b commit 126dab1

22 files changed

Lines changed: 447 additions & 30 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ on:
1010
jobs:
1111
build:
1212
runs-on: windows-latest
13+
timeout-minutes: 30
14+
permissions:
15+
contents: read
1316
steps:
1417
- uses: actions/checkout@v6
1518

.github/workflows/codeql.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ jobs:
88
analyze:
99
name: Analyze
1010
runs-on: ubuntu-latest
11+
timeout-minutes: 30
1112
permissions:
1213
actions: read
1314
contents: read
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Contract registry live drift
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "17 5 * * 1"
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
verify-v1-1:
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 10
15+
steps:
16+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
17+
- name: Compare published v1.1.1 release with vendored contracts
18+
env:
19+
REGISTRY_BASE: https://coding-autopilot-system.github.io/cas-contracts/registry
20+
PINNED_VERSION: 1.1.1
21+
VENDORED_ROOT: src/GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.1
22+
shell: python
23+
run: |
24+
import hashlib, json, os, pathlib, urllib.request
25+
26+
def canonical_bytes(data: bytes) -> bytes:
27+
return data.replace(b"\r\n", b"\n")
28+
29+
def sha256_hex(data: bytes) -> str:
30+
return hashlib.sha256(canonical_bytes(data)).hexdigest()
31+
32+
base = os.environ["REGISTRY_BASE"]
33+
version = os.environ["PINNED_VERSION"]
34+
root = pathlib.Path(os.environ["VENDORED_ROOT"])
35+
with urllib.request.urlopen(f"{base}/index.json", timeout=20) as response:
36+
index = json.load(response)
37+
if version not in index.get("releases", []):
38+
raise SystemExit(f"published registry is missing release {version}")
39+
with urllib.request.urlopen(f"{base}/releases/v{version}/manifest.json", timeout=20) as response:
40+
manifest = json.load(response)
41+
for entry in manifest["schemas"]:
42+
local = (root / entry["path"]).read_bytes()
43+
if sha256_hex(local) != entry["sha256"]:
44+
raise SystemExit(f"vendored digest drift: {entry['path']}")
45+
with urllib.request.urlopen(f"{base}/releases/v{version}/{entry['path']}", timeout=20) as response:
46+
published = response.read()
47+
if sha256_hex(published) != entry["sha256"]:
48+
raise SystemExit(f"published digest drift: {entry['path']}")
49+
if canonical_bytes(published) != canonical_bytes(local):
50+
raise SystemExit(f"published content drift: {entry['path']}")

.github/workflows/pages.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ on:
66
- master
77
permissions:
88
contents: read
9-
pages: write
10-
id-token: write
119
concurrency:
1210
group: "pages"
1311
cancel-in-progress: false
1412
jobs:
1513
build:
1614
runs-on: ubuntu-latest
15+
timeout-minutes: 15
1716
steps:
1817
- uses: actions/checkout@v4
1918
- uses: actions/setup-python@v5
@@ -25,10 +24,14 @@ jobs:
2524
with:
2625
path: ./site
2726
deploy:
27+
permissions:
28+
pages: write
29+
id-token: write
2830
environment:
2931
name: github-pages
3032
url: ${{ steps.deployment.outputs.page_url }}
3133
runs-on: ubuntu-latest
34+
timeout-minutes: 10
3235
needs: build
3336
steps:
3437
- name: Deploy to GitHub Pages

.github/workflows/pr-lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ on:
99
jobs:
1010
main:
1111
runs-on: ubuntu-latest
12+
timeout-minutes: 5
13+
permissions:
14+
pull-requests: read
1215
steps:
1316
- uses: amannn/action-semantic-pull-request@v5
1417
env:

.github/workflows/stale.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ on:
66
jobs:
77
stale:
88
runs-on: ubuntu-latest
9+
timeout-minutes: 10
10+
permissions:
11+
issues: write
12+
pull-requests: write
913
steps:
1014
- uses: actions/stale@v8
1115
with:

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ All configuration is environment-variable driven (`.env` or shell):
5353
## Build & Test
5454

5555
```powershell
56-
dotnet build src/GsdOrchestrator.sln
56+
dotnet build src/GsdOrchestrator/GsdOrchestrator.csproj
57+
dotnet build src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj
5758
dotnet test src/GsdOrchestrator.Tests/GsdOrchestrator.Tests.csproj
5859
```
5960

src/GsdOrchestrator.Tests/ContractCompatibilityTests.cs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Security.Cryptography;
2+
using System.Text;
23
using System.Text.Json;
34
using System.Text.Json.Nodes;
45
using GsdOrchestrator.Workflows.Models;
@@ -18,24 +19,35 @@ namespace GsdOrchestrator.Tests;
1819
/// check self-contained and offline (no JSON-Schema validator NuGet dependency).
1920
///
2021
/// Cross-repo reference note: cas-contracts is a separate repo. In isolated CI only
21-
/// this repo is checked out, so the pinned v1.1.0 release is vendored under
22-
/// GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.0/ (same convention as the
22+
/// this repo is checked out, so the pinned v1.1.1 release is vendored under
23+
/// GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.1/ (same convention as the
2324
/// Python consumers). When the sibling ../cas-contracts checkout is present
2425
/// (local dev), an extra test asserts the vendored copy has not drifted upstream.
2526
/// </summary>
2627
public class ContractCompatibilityTests
2728
{
2829
// The cas-contracts version this consumer targets. Kept in lockstep with the
2930
// vendored release and with SdlcProfile.CasSdlcV1.ProfileVersion.
31+
private const string PinnedReleaseVersion = "1.1.1";
3032
private const string PinnedSchemaVersion = "1.1.0";
3133
private const string PinnedProfileVersion = "v1.1";
3234

3335
private static string ContractRoot { get; } = Path.Combine(
34-
AppContext.BaseDirectory, "Contracts", "cas-contracts", $"v{PinnedSchemaVersion}");
36+
AppContext.BaseDirectory, "Contracts", "cas-contracts", $"v{PinnedReleaseVersion}");
3537

3638
private static JsonNode LoadSchema(string name) =>
3739
JsonNode.Parse(File.ReadAllText(Path.Combine(ContractRoot, name)))!;
3840

41+
private static byte[] ReadCanonicalUtf8Bytes(string path)
42+
{
43+
var text = File.ReadAllText(path);
44+
var normalized = text.ReplaceLineEndings("\n");
45+
return Encoding.UTF8.GetBytes(normalized);
46+
}
47+
48+
private static string Sha256Hex(byte[] bytes) =>
49+
Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
50+
3951
/// <summary>Walk up from the test output directory to find a sibling
4052
/// cas-contracts/registry/releases/v{version} checkout, or null if absent.</summary>
4153
private static string? ResolveUpstreamRoot()
@@ -44,7 +56,7 @@ private static JsonNode LoadSchema(string name) =>
4456
while (dir is not null)
4557
{
4658
var candidate = Path.Combine(
47-
dir.FullName, "cas-contracts", "registry", "releases", $"v{PinnedSchemaVersion}");
59+
dir.FullName, "cas-contracts", "registry", "releases", $"v{PinnedReleaseVersion}");
4860
if (Directory.Exists(candidate))
4961
{
5062
return candidate;
@@ -115,18 +127,41 @@ private static JsonObject BuildProfileWirePayload()
115127
public void VendoredRelease_MatchesManifestHashes()
116128
{
117129
var manifest = LoadSchema("manifest.json");
118-
Assert.Equal(PinnedSchemaVersion, (string?)manifest["version"]);
130+
Assert.Equal(PinnedReleaseVersion, (string?)manifest["version"]);
119131

120132
foreach (var entry in manifest["schemas"]!.AsArray())
121133
{
122134
var path = (string)entry!["path"]!;
123135
var expected = (string)entry["sha256"]!;
124-
var bytes = File.ReadAllBytes(Path.Combine(ContractRoot, path));
125-
var actual = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
136+
var bytes = ReadCanonicalUtf8Bytes(Path.Combine(ContractRoot, path));
137+
var actual = Sha256Hex(bytes);
126138
Assert.Equal(expected, actual);
127139
}
128140
}
129141

142+
[Fact]
143+
public void CanonicalSchemaDigest_IgnoresLineEndings()
144+
{
145+
var crlfPath = Path.GetTempFileName();
146+
var lfPath = Path.GetTempFileName();
147+
148+
try
149+
{
150+
File.WriteAllText(crlfPath, "{\r\n \"name\": \"schema\"\r\n}\r\n", new UTF8Encoding(false));
151+
File.WriteAllText(lfPath, "{\n \"name\": \"schema\"\n}\n", new UTF8Encoding(false));
152+
153+
var crlf = Sha256Hex(ReadCanonicalUtf8Bytes(crlfPath));
154+
var lf = Sha256Hex(ReadCanonicalUtf8Bytes(lfPath));
155+
156+
Assert.Equal(crlf, lf);
157+
}
158+
finally
159+
{
160+
File.Delete(crlfPath);
161+
File.Delete(lfPath);
162+
}
163+
}
164+
130165
[Fact]
131166
public void PinnedVersion_IsTheVersionThisConsumerEmits()
132167
{
@@ -214,8 +249,8 @@ public void VendoredRelease_MatchesUpstreamSourceOfTruth()
214249
{
215250
var upstream = Path.Combine(upstreamRoot, Path.GetFileName(file));
216251
Assert.True(File.Exists(upstream), $"upstream missing {Path.GetFileName(file)}");
217-
var local = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(file)));
218-
var remote = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(upstream)));
252+
var local = Sha256Hex(ReadCanonicalUtf8Bytes(file));
253+
var remote = Sha256Hex(ReadCanonicalUtf8Bytes(upstream));
219254
Assert.Equal(remote, local);
220255
}
221256
}

src/GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.0/common.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://json-schema.org/draft/2020-12/schema",
3-
"$id": "https://schemas.coding-autopilot.dev/v1.1/common.schema.json",
3+
"$id": "https://coding-autopilot-system.github.io/cas-contracts/registry/v1.1/common.schema.json",
44
"title": "CAS Common Definitions v1.1",
55
"$defs": {
66
"actor": {

src/GsdOrchestrator.Tests/Contracts/cas-contracts/v1.1.0/manifest.json

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,34 @@
22
"version": "1.1.0",
33
"schemas": [
44
{
5-
"id": "https://schemas.coding-autopilot.dev/v1.1/common.schema.json",
5+
"id": "https://coding-autopilot-system.github.io/cas-contracts/registry/v1.1/common.schema.json",
66
"path": "common.schema.json",
7-
"sha256": "078026d60c219658fe0fc5cf7c4f786acdcf0c492121f7d688d5078b33ca25af"
7+
"sha256": "111ea168b873aa99a5f2d731f1295320c83c8424156546be9dbdc5624c597562"
88
},
99
{
10-
"id": "https://schemas.coding-autopilot.dev/v1.1/phase-execution-request.schema.json",
10+
"id": "https://coding-autopilot-system.github.io/cas-contracts/registry/v1.1/phase-execution-request.schema.json",
1111
"path": "phase-execution-request.schema.json",
12-
"sha256": "927ffda5ba809e58627ba25b3275355644b510b0ae1c76f446aa7b7f291f08fd"
12+
"sha256": "a9dd60ade628f98cdc97fec9900f4b429f81e0fe07413a2ff21cd42188f85fb6"
1313
},
1414
{
15-
"id": "https://schemas.coding-autopilot.dev/v1.1/phase-execution-result.schema.json",
15+
"id": "https://coding-autopilot-system.github.io/cas-contracts/registry/v1.1/phase-execution-result.schema.json",
1616
"path": "phase-execution-result.schema.json",
17-
"sha256": "1d353e20cf6338c2de4a9bec0bad83ca0dec6c9feb5434c5e6264fdc075e7c25"
17+
"sha256": "7c56cc51aa0f8ae878a4f8219a4b1bedc20697f57d557797a0907a7163df1f7e"
1818
},
1919
{
20-
"id": "https://schemas.coding-autopilot.dev/v1.1/phase-verification-result.schema.json",
20+
"id": "https://coding-autopilot-system.github.io/cas-contracts/registry/v1.1/phase-verification-result.schema.json",
2121
"path": "phase-verification-result.schema.json",
22-
"sha256": "ec5b97a2bf75892530442f666d23a8b6dbb8f13bda0efe49553d3f64cd41b995"
22+
"sha256": "800cfceecd61a16883a5be67f0da9ff9fa8865450c62e92676a878189c12b6cc"
2323
},
2424
{
25-
"id": "https://schemas.coding-autopilot.dev/v1.1/sdlc-lifecycle-event.schema.json",
25+
"id": "https://coding-autopilot-system.github.io/cas-contracts/registry/v1.1/sdlc-lifecycle-event.schema.json",
2626
"path": "sdlc-lifecycle-event.schema.json",
27-
"sha256": "bd955637761679d3b6e8ee09fb7b693b1d0362615b0865204c5ae3bacb3809dc"
27+
"sha256": "f746915fc3687496be216d0b8bcfec0398a8c5eebb765ff8c63e7c9ee88b174f"
2828
},
2929
{
30-
"id": "https://schemas.coding-autopilot.dev/v1.1/sdlc-profile.schema.json",
30+
"id": "https://coding-autopilot-system.github.io/cas-contracts/registry/v1.1/sdlc-profile.schema.json",
3131
"path": "sdlc-profile.schema.json",
32-
"sha256": "f4974e911e7cb88191c1a331470c8cd7a79d1c7311c963dd70cd9c8f524ebe45"
32+
"sha256": "80e75549b7ef49182aba640576228db6bed674f15847b154bbf83d7953e23334"
3333
}
3434
]
3535
}

0 commit comments

Comments
 (0)