Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 6 additions & 41 deletions .github/workflows/release-skill-archive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<asset>
# (GitHub-resolved "is the latest release" pointer) — so the release here
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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>-<unix>. 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
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>-<unix>`) 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@<version>` 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`
Expand Down
39 changes: 39 additions & 0 deletions tests/release-metadata.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
});
});
Loading