Skip to content

Commit 88b1910

Browse files
committed
feat: add improve headlamp publish action
Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
1 parent d29937e commit 88b1910

5 files changed

Lines changed: 168 additions & 20 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ reviewed commit SHA in production.
88
| Action | Purpose |
99
| --- | --- |
1010
| [headlamp-test](headlamp-test/README.md) | Lint, check formatting and types, test, and build a Headlamp plugin from a configurable directory. Includes a workflow template. |
11-
| [headlamp-publish](headlamp-publish/README.md) | Build a Headlamp plugin, upload its GitHub release archive, and publish versioned Artifact Hub metadata to a configurable branch. |
11+
| [headlamp-publish](headlamp-publish/README.md) | Build a Headlamp plugin or reuse an already published archive, then publish versioned Artifact Hub metadata to a configurable branch. |
1212
| [helm-oci-chart](helm-oci-chart/README.md) | Package and push a Helm chart to an OCI registry, with optional signing. |
1313
| [task-ko-publish](task-ko-publish/README.md) | Publish a ko image through a Task target, generate and attach a CycloneDX SBOM, and optionally sign it. |
1414
| [exists](exists/README.md) | Return whether an input is nonempty. |

‎headlamp-publish/README.md‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Builds a plugin with `npm ci`, `npm run build`, and `npm run package`; uploads i
66
to `headlamp/`. Container-image building, signing, and attestations can follow as
77
separate steps in the caller's workflow.
88

9+
When GoReleaser or another release tool already packages, signs, and uploads the
10+
plugin, pass its archive through `archive` to publish only the Artifact Hub
11+
metadata. The action verifies the released archive's checksum without rebuilding
12+
the plugin, changing source package files, or creating or uploading release assets.
13+
914
```yaml
1015
name: Publish Headlamp plugin
1116
on:
@@ -45,19 +50,50 @@ in the source checkout, without committing them.
4550
| Input | Default | Meaning |
4651
| --- | --- | --- |
4752
| `build-root` | `headlamp/` | Plugin path relative to `GITHUB_WORKSPACE`; `.` builds at the repository root. |
53+
| `archive` | empty | Prebuilt `.tar.gz` path or glob relative to `GITHUB_WORKSPACE`; must match exactly one file already attached to the published release. Skips plugin installation, version changes, building, packaging, and release uploads. |
4854
| `target-branch` | `artifacthub` | Branch to update or create for metadata. |
4955
| `tag` | `github.ref_name` | Existing tag matching the checked-out commit; exact SemVer with optional `v`, including prereleases, without `+` build metadata. |
5056
| `package-name` | `package.json` name | Artifact Hub package name and directory; lowercase letters, digits, and hyphens. Override this for scoped npm names. |
5157
| `metadata-template` | automatic | Optional YAML template path relative to `build-root`. |
5258
| `node-version` | `24` | Node.js version, at least 24. |
5359
| `token` | `github.token` | Token with `contents: write` in the caller repository. |
5460

55-
Requires an Ubuntu runner, a checked-out repository, `package.json`, a compatible
56-
`package-lock.json`, and working `build` and `package` npm scripts. Packaging must
61+
Requires an Ubuntu runner, a checked-out repository, and `package.json`.
62+
Without `archive`, also requires a compatible
63+
`package-lock.json` and working `build` and `package` npm scripts. Packaging must
5764
produce exactly one nonempty `.tar.gz` directly in `build-root`, which must not
5865
already contain archives. Run the plugin's own lint and test checks before this
5966
action. The action executes the plugin's trusted npm scripts.
6067

68+
## Combining with GoReleaser
69+
70+
Include the plugin archive in GoReleaser's archives, checksums, SBOMs, and Cosign
71+
signing. Publish all assets together before the release becomes immutable, then
72+
invoke this action with the exact archive that GoReleaser uploaded:
73+
74+
```yaml
75+
- name: Publish release artifacts
76+
run: devbox run -- task release
77+
env:
78+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
79+
- uses: projectcapsule/github-actions/headlamp-publish@main
80+
with:
81+
build-root: headlamp/
82+
archive: dist/resource-permits-*.tar.gz
83+
target-branch: headlamp-plugin
84+
metadata-template: artifacthub-pkg.yml
85+
```
86+
87+
Pin a reviewed action commit SHA in production. Run this only after the release
88+
tool succeeds, in the same tag-only job with `contents: write`. Keep pull-request
89+
checks in a separate read-only workflow.
90+
91+
The glob must identify one nonempty archive within the source checkout. Symlinks
92+
and paths escaping the checkout are rejected. Existing releases and assets must
93+
already exist and match the local SHA-256. A missing release, missing asset, or
94+
checksum mismatch fails before publishing metadata. Cosign bundles remain owned
95+
by the release tool; this action leaves all release assets intact.
96+
6197
## Metadata and first publication
6298

6399
Templates are selected in this order:

‎headlamp-publish/action.yaml‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: Publish Headlamp plugin
2-
description: Build a plugin, publish its GitHub release archive, and update an Artifact Hub branch.
2+
description: Build or reuse a plugin archive and publish versioned Artifact Hub metadata.
33

44
inputs:
55
token:
@@ -10,6 +10,9 @@ inputs:
1010
description: Plugin directory relative to the checked-out repository.
1111
required: false
1212
default: headlamp/
13+
archive:
14+
description: Prebuilt .tar.gz path or glob relative to the checkout. Must match one archive already attached to the release; skips plugin builds and release uploads.
15+
required: false
1316
target-branch:
1417
description: Branch containing the versioned Artifact Hub metadata.
1518
required: false
@@ -61,12 +64,13 @@ runs:
6164
env:
6265
ACTION_PATH: ${{ github.action_path }}
6366
run: npm ci --ignore-scripts --no-audit --no-fund --prefix "$ACTION_PATH/.."
64-
- name: Build and package plugin
67+
- name: Prepare plugin archive
6568
id: build
6669
shell: bash
6770
env:
6871
ACTION_PATH: ${{ github.action_path }}
6972
INPUT_BUILD_ROOT: ${{ inputs.build-root }}
73+
INPUT_ARCHIVE: ${{ inputs.archive }}
7074
INPUT_TARGET_BRANCH: ${{ inputs.target-branch }}
7175
INPUT_TAG: ${{ inputs.tag }}
7276
INPUT_PACKAGE_NAME: ${{ inputs.package-name }}
@@ -79,7 +83,7 @@ runs:
7983
STATE_FILE: ${{ steps.build.outputs.state }}
8084
GH_TOKEN: ${{ inputs.token }}
8185
run: node "$ACTION_PATH/publish.mjs" prepare
82-
- name: Publish release archive
86+
- name: Verify or publish release archive
8387
shell: bash
8488
env:
8589
ACTION_PATH: ${{ github.action_path }}

‎headlamp-publish/publish.mjs‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,28 +87,38 @@ export function build(env = process.env) {
8787
}
8888
const template = env.INPUT_METADATA_TEMPLATE ? within(root, env.INPUT_METADATA_TEMPLATE) : null;
8989
if (template && !fs.statSync(template).isFile()) throw new Error('Metadata template must be a file');
90-
if (fs.readdirSync(root).some(file => file.endsWith('.tar.gz'))) {
91-
throw new Error('Remove existing .tar.gz archives from build-root before packaging');
92-
}
9390
const sourceCommit = run('git', ['rev-parse', 'HEAD'], { cwd: workspace });
94-
const options = { cwd: root, env, stdio: 'inherit' };
95-
run('npm', ['ci', '--no-audit', '--no-fund'], options);
96-
run('npm', ['version', version, '--no-git-tag-version', '--allow-same-version', '--ignore-scripts'], options);
97-
run('npm', ['run', 'build'], options);
98-
run('npm', ['run', 'package'], options);
99-
const archives = fs.readdirSync(root).filter(file => file.endsWith('.tar.gz'));
100-
if (archives.length !== 1) throw new Error('Packaging must produce exactly one .tar.gz archive in build-root');
101-
const archive = within(root, archives[0]);
91+
const prebuilt = Boolean(env.INPUT_ARCHIVE);
92+
let archive;
93+
if (prebuilt) {
94+
within(workspace, env.INPUT_ARCHIVE);
95+
const archives = fs.globSync(env.INPUT_ARCHIVE, { cwd: workspace });
96+
if (archives.length !== 1) throw new Error('archive must match exactly one .tar.gz file within the checkout');
97+
archive = within(workspace, archives[0]);
98+
if (!archive.endsWith('.tar.gz')) throw new Error('archive must be a .tar.gz file');
99+
} else {
100+
if (fs.readdirSync(root).some(file => file.endsWith('.tar.gz'))) {
101+
throw new Error('Remove existing .tar.gz archives from build-root before packaging');
102+
}
103+
const options = { cwd: root, env, stdio: 'inherit' };
104+
run('npm', ['ci', '--no-audit', '--no-fund'], options);
105+
run('npm', ['version', version, '--no-git-tag-version', '--allow-same-version', '--ignore-scripts'], options);
106+
run('npm', ['run', 'build'], options);
107+
run('npm', ['run', 'package'], options);
108+
const archives = fs.readdirSync(root).filter(file => file.endsWith('.tar.gz'));
109+
if (archives.length !== 1) throw new Error('Packaging must produce exactly one .tar.gz archive in build-root');
110+
archive = within(root, archives[0]);
111+
}
102112
if (!fs.statSync(archive).isFile() || fs.statSync(archive).size === 0) throw new Error('Plugin archive is empty or not a file');
103113
const server = env.GITHUB_SERVER_URL || 'https://github.com';
104114
const directory = fs.mkdtempSync(path.join(env.RUNNER_TEMP || os.tmpdir(), 'headlamp-publish-'));
105115
const state = {
106116
stateFile: path.join(directory, 'state.json'), directory, root, workspace,
107-
sourceCommit, tag, version, branch, name, template, archive, checksum: sha256(archive),
117+
sourceCommit, tag, version, branch, name, template, archive, prebuilt, checksum: sha256(archive),
108118
repository: env.GITHUB_REPOSITORY, server,
109119
api: env.GITHUB_API_URL || 'https://api.github.com',
110120
remote: `${server}/${env.GITHUB_REPOSITORY}.git`,
111-
archiveURL: `${server}/${env.GITHUB_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(archives[0])}`,
121+
archiveURL: `${server}/${env.GITHUB_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(path.basename(archive))}`,
112122
metadataPath: `${name}/${version}/artifacthub-pkg.yml`,
113123
};
114124
save(state);
@@ -236,6 +246,9 @@ export async function release(state, token, fetcher = fetch) {
236246
throw new Error('Release tag does not resolve to the checked-out source commit');
237247
}
238248
const found = await request(`${base}/releases/tags/${encodeURIComponent(state.tag)}`, {}, true);
249+
if (!found && state.prebuilt) {
250+
throw new Error('Prebuilt archive requires an existing published release; publish it before running this action');
251+
}
239252
const published = found ? await found.json() : await (await request(`${base}/releases`, {
240253
method: 'POST', headers: { 'Content-Type': 'application/json' },
241254
body: JSON.stringify({ tag_name: state.tag, name: `Release ${state.tag}`, generate_release_notes: true,
@@ -259,6 +272,12 @@ export async function release(state, token, fetcher = fetch) {
259272
if (digest !== `sha256:${state.checksum}`) throw new Error('Release asset already exists with different content; publish a new version');
260273
return;
261274
}
275+
if (state.prebuilt) {
276+
throw new Error('Prebuilt archive is not attached to the release; publish it and its signatures before metadata');
277+
}
278+
if (published.immutable) {
279+
throw new Error('Cannot add an archive to an immutable release; include it before publication and use the archive input');
280+
}
262281
const upload = new URL(published.upload_url.replace(/\{.*$/, ''));
263282
upload.searchParams.set('name', filename);
264283
await request(upload, { method: 'POST', headers: { 'Content-Type': 'application/gzip' }, body: await fs.openAsBlob(state.archive) });

‎tests/headlamp.test.mjs‎

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,74 @@ function commit(checkout, message = 'fixture') {
4242
'commit', '--quiet', '-m', message], { cwd: checkout });
4343
}
4444

45+
function prebuiltFixture(t) {
46+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'headlamp-prebuilt-'));
47+
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
48+
const workspace = path.join(directory, 'workspace');
49+
const root = path.join(workspace, 'headlamp');
50+
const dist = path.join(workspace, 'dist');
51+
fs.mkdirSync(root, { recursive: true });
52+
fs.mkdirSync(dist);
53+
const pkg = JSON.stringify({ name: 'capsule', version: '0.0.0',
54+
scripts: { build: 'exit 99', package: 'exit 99' } });
55+
fs.writeFileSync(path.join(root, 'package.json'), pkg);
56+
const archive = path.join(dist, 'capsule-1.2.3.tar.gz');
57+
fs.writeFileSync(archive, 'prebuilt archive bytes');
58+
run('git', ['init', '--quiet', workspace]);
59+
commit(workspace);
60+
const env = { ...process.env, GITHUB_WORKSPACE: workspace, RUNNER_TEMP: directory,
61+
GITHUB_OUTPUT: path.join(directory, 'outputs'), GITHUB_REPOSITORY: 'projectcapsule/example',
62+
INPUT_TAG: 'v1.2.3', INPUT_BUILD_ROOT: 'headlamp/', INPUT_ARCHIVE: 'dist/*.tar.gz' };
63+
return { directory, workspace, root, dist, archive, pkg, env };
64+
}
65+
66+
test('prebuilt archives skip npm and reuse an immutable release without API mutations', async t => {
67+
const f = prebuiltFixture(t);
68+
fs.writeFileSync(path.join(f.root, 'stale.tar.gz'), 'unrelated plugin archive');
69+
const state = build(f.env);
70+
assert.equal(state.archive, fs.realpathSync(f.archive));
71+
assert.equal(state.prebuilt, true);
72+
assert.equal(state.checksum, sha256(f.archive));
73+
assert.equal(fs.readFileSync(path.join(f.root, 'package.json'), 'utf8'), f.pkg);
74+
assert.equal(fs.existsSync(path.join(f.root, 'package-lock.json')), false);
75+
assert.equal(fs.existsSync(path.join(f.root, 'node_modules')), false);
76+
assert.equal(fs.readFileSync(f.archive, 'utf8'), 'prebuilt archive bytes');
77+
assert.match(fs.readFileSync(f.env.GITHUB_OUTPUT, 'utf8'), /metadata-path=capsule\/1.2.3\/artifacthub-pkg.yml/);
78+
const api = fakeGitHub(state, { exists: true, immutable: true, asset: { digest: `sha256:${state.checksum}` } });
79+
await release(state, 'test-token', api.fetch);
80+
assert.equal(api.calls.filter(call => ['POST', 'DELETE', 'PATCH'].includes(call.method)).length, 0);
81+
});
82+
83+
test('prebuilt archive paths preserve spaces and encode the release download URL', t => {
84+
const f = prebuiltFixture(t);
85+
const filename = 'plugin archive.tar.gz';
86+
fs.renameSync(f.archive, path.join(f.dist, filename));
87+
f.env.INPUT_ARCHIVE = `dist/${filename}`;
88+
const state = build(f.env);
89+
assert.equal(state.archive, fs.realpathSync(path.join(f.dist, filename)));
90+
assert.ok(state.archiveURL.endsWith('/v1.2.3/plugin%20archive.tar.gz'));
91+
});
92+
93+
for (const [name, mutate, expected] of [
94+
['no matches', f => { f.env.INPUT_ARCHIVE = 'dist/missing*.tar.gz'; }, /exactly one/],
95+
['multiple matches', f => { fs.writeFileSync(path.join(f.dist, 'other.tar.gz'), 'other'); }, /exactly one/],
96+
['empty file', f => { fs.writeFileSync(f.archive, ''); }, /empty or not a file/],
97+
['directory', f => { fs.unlinkSync(f.archive); fs.mkdirSync(f.archive); }, /empty or not a file/],
98+
['wrong extension', f => { fs.renameSync(f.archive, path.join(f.dist, 'plugin.zip')); f.env.INPUT_ARCHIVE = 'dist/*.zip'; }, /\.tar\.gz/],
99+
['absolute path', f => { f.env.INPUT_ARCHIVE = f.archive; }, /relative path/],
100+
['path traversal', f => { f.env.INPUT_ARCHIVE = '../outside/*.tar.gz'; }, /escapes/],
101+
['archive symlink', f => { const link = path.join(f.dist, 'link.tar.gz'); fs.symlinkSync(f.archive, link); f.env.INPUT_ARCHIVE = 'dist/link.tar.gz'; }, /Symlinks/],
102+
['directory symlink', f => { fs.symlinkSync(f.dist, path.join(f.workspace, 'linked')); f.env.INPUT_ARCHIVE = 'linked/*.tar.gz'; }, /Symlinks/],
103+
]) {
104+
test(`prebuilt archive rejects ${name} before publication`, t => {
105+
const f = prebuiltFixture(t);
106+
mutate(f);
107+
assert.throws(() => build(f.env), expected);
108+
assert.equal(fs.existsSync(f.env.GITHUB_OUTPUT), false);
109+
assert.equal(fs.readFileSync(path.join(f.root, 'package.json'), 'utf8'), f.pkg);
110+
});
111+
}
112+
45113
test('first publication creates only the selected metadata branch, repeat is unchanged', t => {
46114
const { state, env } = fixture(t);
47115
prepare(state, env);
@@ -196,7 +264,7 @@ function fakeGitHub(state, options = {}) {
196264
assert.equal(init.headers.Authorization, 'Bearer test-token');
197265
if (address.includes('/git/ref/tags/')) return json({ object: options.annotated ? { type: 'tag', sha: 'tagsha' } : { type: 'commit', sha: options.commit || state.sourceCommit } });
198266
if (address.includes('/git/tags/')) return json({ object: { type: 'commit', sha: state.sourceCommit } });
199-
if (address.includes('/releases/tags/')) return options.exists ? json({ id: 7 }) : json({}, options.error || 404);
267+
if (address.includes('/releases/tags/')) return options.exists ? json({ id: 7, immutable: Boolean(options.immutable) }) : json({}, options.error || 404);
200268
if (address.endsWith('/releases') && init.method === 'POST') return json({ id: 7, upload_url: 'https://uploads.github.com/repos/projectcapsule/example/releases/7/assets{?name,label}' }, 201);
201269
if (address.includes('?per_page=')) return json(options.asset ? [{ id: 9, name: path.basename(state.archive), ...options.asset }] : []);
202270
if (address.includes('/releases/assets/9')) return new Response(fs.readFileSync(state.archive));
@@ -227,6 +295,27 @@ test('identical assets are reused with digest or download verification', async t
227295
}
228296
});
229297

298+
test('prebuilt archives reject missing releases, missing assets, and changed bytes without API mutations', async t => {
299+
const { state } = fixture(t);
300+
state.prebuilt = true;
301+
for (const [options, expected] of [
302+
[{}, /existing published release/],
303+
[{ exists: true, immutable: true }, /not attached to the release/],
304+
[{ exists: true, asset: { digest: `sha256:${'b'.repeat(64)}` } }, /different content/],
305+
]) {
306+
const api = fakeGitHub(state, options);
307+
await assert.rejects(release(state, 'test-token', api.fetch), expected);
308+
assert.equal(api.calls.filter(call => ['POST', 'DELETE', 'PATCH'].includes(call.method)).length, 0);
309+
}
310+
});
311+
312+
test('missing archives on immutable releases fail clearly before attempting an upload', async t => {
313+
const { state } = fixture(t);
314+
const api = fakeGitHub(state, { exists: true, immutable: true });
315+
await assert.rejects(release(state, 'test-token', api.fetch), /Cannot add an archive to an immutable release/);
316+
assert.equal(api.calls.filter(call => ['POST', 'DELETE', 'PATCH'].includes(call.method)).length, 0);
317+
});
318+
230319
test('release does not overwrite different assets or hide authorization failures', async t => {
231320
const { state } = fixture(t);
232321
for (const [options, error] of [

0 commit comments

Comments
 (0)