diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba180be..36739e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,7 @@ jobs: run: bash tests/claude-hooks.sh - name: Candidate archive contract - run: node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs + run: node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs tests/release-metadata.test.mjs - name: ShellCheck # Pinned to v2.0.0 by full commit SHA — public-repo supply-chain diff --git a/.github/workflows/release-skill-archive.yml b/.github/workflows/release-skill-archive.yml index b734580..4d4a3c5 100644 --- a/.github/workflows/release-skill-archive.yml +++ b/.github/workflows/release-skill-archive.yml @@ -5,7 +5,7 @@ name: Release skill archive # proxies: # - SKILL.md (release-level standalone copy) # - display-dev.tar.gz (deterministic tarball; SKILL.md at archive root) -# - checksums.json (sha256 + size of both assets + sourceSha + builtAt) +# - checksums.json (sha256 + size of both assets + sourceSha + builtAt + releaseTag) # # The website resolves https://github.com/display-dev/skill/releases/latest/download/ # (GitHub-resolved "is the latest release" pointer) — so the release here @@ -20,9 +20,8 @@ name: Release skill archive # dated-tag download path (not /releases/latest/download/). # 3. Mark the dated release latest with `gh release edit --latest`. # This is the published pointer the website consumes. -# 4. Retention sweep: keep the latest 10 dated releases, delete the -# rest. Older releases stay reachable by tag-specific URL even -# after a newer release takes the latest flag — useful for forensics. +# 4. Preserve dated releases: cached discovery indexes name their exact +# release, so publishing a new release must not delete older assets. on: push: @@ -53,7 +52,7 @@ jobs: - name: Validate packaging and standalone instructions run: | - node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs + node --test tests/archive-candidate.test.mjs tests/standalone.test.mjs tests/release-metadata.test.mjs bash tests/run.sh - name: Compute identifiers @@ -169,12 +168,13 @@ jobs: "skillMd": { "sha256": "${{ steps.build.outputs.skill_md_sha }}", "size": ${{ steps.build.outputs.skill_md_size }} }, "archive": { "sha256": "${{ steps.build.outputs.archive_sha }}", "size": ${{ steps.build.outputs.archive_size }} }, "sourceSha": "${{ steps.ids.outputs.source_sha }}", + "releaseTag": "${{ steps.ids.outputs.tag }}", "builtAt": "${{ steps.ids.outputs.built_at }}" } EOF # Validate the file is JSON the website can parse (matches the # zod schema at website/src/app/.well-known/agent-skills/[...path]/route.ts). - python3 -c 'import json,sys; d=json.load(open("checksums.json")); assert "skillMd" in d and "archive" in d and "sourceSha" in d and "builtAt" in d' + python3 -c 'import json,sys; d=json.load(open("checksums.json")); assert "skillMd" in d and "archive" in d and "sourceSha" in d and "builtAt" in d and "releaseTag" in d' - name: Create dated release (not latest yet) env: @@ -241,38 +241,3 @@ jobs: # flag GitHub uses to resolve /releases/latest/download/. The # website's route handler reads via that path. gh release edit "${{ steps.ids.outputs.tag }}" --latest - - - name: Retention sweep (keep newest 10 dated releases) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - # gh release list orders newest-first by default. The jq filter - # matches exactly this workflow's tag shape: - # release-<40-hex-sha>-. Anything else (release-notes, - # release-candidate, a human-created tag, …) is untouched. - # Drop the first 10 from the matched list, delete the rest. - PATTERN='^release-[0-9a-f]{40}-[0-9]+$' - TO_DELETE=$(gh release list --limit 100 --json tagName \ - --jq "[.[] | select(.tagName | test(\"${PATTERN}\")) | .tagName] | .[10:][]") - if [ -z "${TO_DELETE}" ]; then - echo "retention sweep: nothing to prune" - exit 0 - fi - # Try every delete, collect failures, surface non-zero exit - # at the end so the workflow reflects reality. Don't `|| true` - # — a silent failure here means the sweep doesn't actually - # shrink the release list and the next run repeats the same - # work. - failed=() - while IFS= read -r tag; do - [ -z "$tag" ] && continue - echo "pruning $tag" - if ! gh release delete "$tag" --yes --cleanup-tag; then - failed+=("$tag") - fi - done <<< "${TO_DELETE}" - if [ "${#failed[@]}" -gt 0 ]; then - echo "retention sweep failed for: ${failed[*]}" - exit 1 - fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a12fd98..0cbd1de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,6 +185,13 @@ Verify those separately after the release is live. ## Releasing +Dated release assets are preserved: discovery indexes may continue advertising +an older release after a newer one becomes latest. `checksums.json` includes +`releaseTag` (`release-<40-hex-sourceSha>-`) alongside the asset digests, +sizes, `sourceSha`, and `builtAt`; the website uses it to bind download URLs to +the release whose digests it advertises. Do not replace assets on a dated release +or delete them as part of routine publishing. + `SKILL_VERSION` in `display-dev/scripts/_common.sh` is the single source of truth for the release version — it travels on `X-Client-Source: display-dev-skill@` for analytics attribution. Bump it in lockstep with every git tag, and bump it together with every other version-bearing file, because CI asserts they all match: - `.cursor-plugin/plugin.json` diff --git a/tests/release-metadata.test.mjs b/tests/release-metadata.test.mjs new file mode 100644 index 0000000..908f5ff --- /dev/null +++ b/tests/release-metadata.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +test('published checksums bind both assets to their exact dated release', (t) => { + const root = path.resolve(import.meta.dirname, '..'); + const workflow = readFileSync(path.join(root, '.github/workflows/release-skill-archive.yml'), 'utf8'); + const step = workflow.match(/ - name: Write checksums\.json\n run: \|\n([\s\S]*?)(?=\n - name:)/); + assert.ok(step, 'release workflow must write checksums before publishing'); + const sourceSha = 'a'.repeat(40); + const releaseTag = `release-${sourceSha}-1792598742`; + const values = { + 'steps.build.outputs.skill_md_sha': 'b'.repeat(64), + 'steps.build.outputs.skill_md_size': '123', + 'steps.build.outputs.archive_sha': 'c'.repeat(64), + 'steps.build.outputs.archive_size': '456', + 'steps.ids.outputs.source_sha': sourceSha, + 'steps.ids.outputs.built_at': '2026-10-21T16:05:42Z', + 'steps.ids.outputs.tag': releaseTag, + }; + const script = step[1].replace(/^ /gm, '').replace(/\$\{\{ ([^}]+) \}\}/g, (_, key) => { + assert.ok(Object.hasOwn(values, key), `unexpected release expression: ${key}`); + return values[key]; + }); + const cwd = mkdtempSync(path.join(os.tmpdir(), 'display-release-metadata-')); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + execFileSync('bash', ['-c', script], { cwd }); + const metadata = JSON.parse(readFileSync(path.join(cwd, 'checksums.json'), 'utf8')); + assert.deepEqual(metadata, { + skillMd: { sha256: 'b'.repeat(64), size: 123 }, + archive: { sha256: 'c'.repeat(64), size: 456 }, + sourceSha, + builtAt: values['steps.ids.outputs.built_at'], + releaseTag, + }); +});