diff --git a/.github/workflows/install-verify.yml b/.github/workflows/install-verify.yml
new file mode 100644
index 0000000..9377ffa
--- /dev/null
+++ b/.github/workflows/install-verify.yml
@@ -0,0 +1,332 @@
+name: Verify Published Installation
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ release_tag:
+ description: Release tag to verify
+ required: true
+ default: v0.1.0
+ type: string
+ source_run_id:
+ description: Tagged Release dry-run ID; leave empty to verify a published release
+ required: false
+ default: ""
+ type: string
+
+permissions:
+ contents: read
+
+concurrency:
+ group: install-verify-${{ inputs.release_tag || github.event.release.tag_name }}
+ cancel-in-progress: false
+
+jobs:
+ node-package:
+ name: Node package / ${{ matrix.os }}
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 12
+ permissions:
+ actions: read
+ attestations: read
+ contents: read
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-14]
+ steps:
+ - name: Install Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24.15.0
+ package-manager-cache: false
+ - name: Download, verify, and exercise the Node package
+ env:
+ ARTIFACTSERVER_SOURCE_RUN_ID: ${{ inputs.source_run_id }}
+ ARTIFACTSERVER_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ work="$RUNNER_TEMP/artifact-server-install"
+ assets="$work/assets"
+ install="$work/install"
+ data="$work/data"
+ mkdir -p "$assets" "$install"
+
+ if [[ -n "$ARTIFACTSERVER_SOURCE_RUN_ID" ]]; then
+ gh run download "$ARTIFACTSERVER_SOURCE_RUN_ID" \
+ --repo "$GITHUB_REPOSITORY" \
+ --pattern 'release-public-*' \
+ --dir "$assets"
+ else
+ gh release download "$ARTIFACTSERVER_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --dir "$assets"
+ fi
+
+ checksums=$(find "$assets" -type f -name SHA256SUMS -print -quit)
+ if [[ -z "$checksums" ]]; then
+ printf 'SHA256SUMS is missing from the downloaded release assets.\n' >&2
+ exit 1
+ fi
+ release_directory=$(dirname "$checksums")
+ if command -v sha256sum >/dev/null 2>&1; then
+ (cd "$release_directory" && sha256sum --check --strict SHA256SUMS)
+ else
+ (cd "$release_directory" && shasum -a 256 --check SHA256SUMS)
+ fi
+
+ version=${ARTIFACTSERVER_TAG#v}
+ archive="$release_directory/artifact-server-$version-node.tar.gz"
+ if [[ ! -f "$archive" ]]; then
+ printf 'The Node package for %s is missing.\n' "$ARTIFACTSERVER_TAG" >&2
+ exit 1
+ fi
+ if [[ -z "$ARTIFACTSERVER_SOURCE_RUN_ID" ]]; then
+ gh attestation verify "$archive" \
+ --repo "$GITHUB_REPOSITORY" \
+ --deny-self-hosted-runners
+ else
+ printf '%s\n' \
+ '::notice::Dry-run assets are checksum-verified; attestations exist only for tagged releases.'
+ fi
+
+ tar -xzf "$archive" -C "$install"
+ artifactserver="$install/artifactserver/bin/artifactserver"
+ test -x "$artifactserver"
+ test "$("$artifactserver" --version)" = "$version"
+
+ fixture="$work/install-proof.html"
+ printf '%s\n' '
Clean install proof' > "$fixture"
+ "$artifactserver" start --data "$data" --port 8787 > "$work/server.log" 2>&1 &
+ server_pid=$!
+ cleanup() {
+ kill "$server_pid" 2>/dev/null || true
+ wait "$server_pid" 2>/dev/null || true
+ if [[ -f "$data/local-service.json" ]]; then
+ managed_pid=$(node -p \
+ "JSON.parse(require('node:fs').readFileSync(process.argv[1], 'utf8')).pid" \
+ "$data/local-service.json")
+ kill "$managed_pid" 2>/dev/null || true
+ fi
+ }
+ trap cleanup EXIT
+ for attempt in {1..40}; do
+ if curl --fail --silent http://127.0.0.1:8787/ready >/dev/null; then
+ break
+ fi
+ if [[ "$attempt" == 40 ]]; then
+ cat "$work/server.log" >&2
+ exit 1
+ fi
+ sleep 0.25
+ done
+
+ publication=$("$artifactserver" publish "$fixture" \
+ --data "$data" \
+ --server http://127.0.0.1:8787 \
+ --token-file "$data/local-api-token" \
+ --public)
+ ARTIFACTSERVER_PUBLICATION="$publication" node --input-type=module <<'NODE'
+ import {request} from "node:http";
+ const publication = JSON.parse(process.env.ARTIFACTSERVER_PUBLICATION);
+ const published = new URL(publication.links.version);
+ const content = await new Promise((resolve, reject) => {
+ const incoming = request({
+ headers: {host: published.host},
+ hostname: "127.0.0.1",
+ path: `${published.pathname}${published.search}`,
+ port: 8787,
+ }, (response) => {
+ let body = "";
+ response.setEncoding("utf8");
+ response.on("data", (chunk) => { body += chunk; });
+ response.on("end", () => {
+ if (response.statusCode !== 200) {
+ reject(new Error(`Published content returned ${response.statusCode}.`));
+ return;
+ }
+ resolve(body);
+ });
+ });
+ incoming.on("error", reject);
+ incoming.end();
+ });
+ if (!content.includes("Clean install proof")) {
+ throw new Error("Published content did not match the clean-install fixture.");
+ }
+ NODE
+
+ kill "$server_pid"
+ wait "$server_pid" || true
+ browser_command="$work/browser-command"
+ printf '%s\n' '#!/bin/sh' 'exit 0' > "$browser_command"
+ chmod 0700 "$browser_command"
+ open_output=$(ARTIFACT_SERVER_BROWSER_COMMAND="$browser_command" \
+ "$artifactserver" open --data "$data")
+ printf '%s\n' "$open_output" | grep -Eq 'https?://[^[:space:]]+'
+
+ image:
+ name: Immutable GHCR image / compact profile
+ if: github.event_name == 'release' || inputs.source_run_id == ''
+ runs-on: ubuntu-latest
+ timeout-minutes: 12
+ permissions:
+ attestations: read
+ contents: read
+ packages: read
+ steps:
+ - name: Install Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24.15.0
+ package-manager-cache: false
+ - name: Run the immutable image through the compact profile
+ env:
+ ARTIFACTSERVER_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ work="$RUNNER_TEMP/artifact-server-image-install"
+ mkdir -p "$work"
+ gh release download "$ARTIFACTSERVER_TAG" \
+ --repo "$GITHUB_REPOSITORY" \
+ --pattern image-reference.txt \
+ --dir "$work"
+ image_reference=$(cat "$work/image-reference.txt")
+ printf '%s' "$GH_TOKEN" | docker login ghcr.io \
+ --username "$GITHUB_ACTOR" \
+ --password-stdin
+ gh attestation verify "oci://$image_reference" \
+ --repo "$GITHUB_REPOSITORY" \
+ --bundle-from-oci \
+ --deny-self-hosted-runners
+
+ gh api \
+ "repos/$GITHUB_REPOSITORY/contents/packaging/compose/compose.yaml?ref=$ARTIFACTSERVER_TAG" \
+ --jq .content | base64 --decode > "$work/compose.yaml"
+ export ARTIFACT_SERVER_CONTENT_DOMAIN=content.example.net
+ export ARTIFACT_SERVER_IMAGE="$image_reference"
+ export ARTIFACT_SERVER_ORIGIN=https://artifacts.example.com
+ export ARTIFACT_SERVER_PORT=8787
+ export ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=0
+ export ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE=0
+ export COMPOSE_PROJECT_NAME=artifact-server-install-verify
+ cleanup() {
+ docker compose --file "$work/compose.yaml" down --volumes \
+ --remove-orphans >/dev/null 2>&1 || true
+ }
+ trap cleanup EXIT
+
+ docker compose --file "$work/compose.yaml" run --rm --no-deps \
+ artifact-server init \
+ --admin-email admin@example.test \
+ --data /var/lib/artifact-server/data
+ docker compose --file "$work/compose.yaml" up --detach
+ for attempt in {1..60}; do
+ if curl --fail --silent http://127.0.0.1:8787/ready >/dev/null; then
+ break
+ fi
+ if [[ "$attempt" == 60 ]]; then
+ docker compose --file "$work/compose.yaml" logs >&2
+ exit 1
+ fi
+ sleep 0.5
+ done
+
+ docker compose --file "$work/compose.yaml" exec -T artifact-server \
+ sh -c "printf '%s\\n' 'OCI install proof' > /tmp/install-proof.html"
+ publication=$(docker compose --file "$work/compose.yaml" exec -T \
+ artifact-server node dist/cli/main.js publish /tmp/install-proof.html \
+ --server http://127.0.0.1:8787 \
+ --token-file /var/lib/artifact-server/data/secrets/api-token \
+ --public)
+ ARTIFACTSERVER_PUBLICATION="$publication" node --input-type=module <<'NODE'
+ import {request} from "node:http";
+ const publication = JSON.parse(process.env.ARTIFACTSERVER_PUBLICATION);
+ const published = new URL(publication.links.version);
+ const body = await new Promise((resolve, reject) => {
+ const incoming = request({
+ headers: {host: published.host},
+ hostname: "127.0.0.1",
+ path: `${published.pathname}${published.search}`,
+ port: 8787,
+ }, (response) => {
+ let content = "";
+ response.setEncoding("utf8");
+ response.on("data", (chunk) => { content += chunk; });
+ response.on("end", () => {
+ if (response.statusCode !== 200) {
+ reject(new Error(`Published content returned ${response.statusCode}.`));
+ return;
+ }
+ resolve(content);
+ });
+ });
+ incoming.on("error", reject);
+ incoming.end();
+ });
+ if (!body.includes("OCI install proof")) {
+ throw new Error("Published content did not match the OCI fixture.");
+ }
+ NODE
+
+ adapter:
+ name: Public npm adapter
+ runs-on: ubuntu-latest
+ timeout-minutes: 6
+ permissions:
+ actions: read
+ contents: read
+ steps:
+ - name: Install Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 24.15.0
+ registry-url: https://registry.npmjs.org
+ package-manager-cache: false
+ - name: Install and load the OpenCode adapter
+ env:
+ ARTIFACTSERVER_SOURCE_RUN_ID: ${{ inputs.source_run_id }}
+ ARTIFACTSERVER_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
+ GH_TOKEN: ${{ github.token }}
+ shell: bash
+ run: | # zizmor: ignore[adhoc-packages] This job proves the published package installs without the source lockfile.
+ set -euo pipefail
+ work="$RUNNER_TEMP/artifact-server-adapter-install"
+ prefix="$work/prefix"
+ mkdir -p "$prefix"
+ version=${ARTIFACTSERVER_TAG#v}
+ if [[ -n "$ARTIFACTSERVER_SOURCE_RUN_ID" ]]; then
+ assets="$work/assets"
+ mkdir -p "$assets"
+ gh run download "$ARTIFACTSERVER_SOURCE_RUN_ID" \
+ --repo "$GITHUB_REPOSITORY" \
+ --pattern 'release-public-*' \
+ --dir "$assets"
+ package_spec=$(find "$assets" -type f \
+ -name "plannotator-artifact-server-opencode-$version.tgz" \
+ -print -quit)
+ if [[ -z "$package_spec" ]]; then
+ printf 'The OpenCode adapter tarball is missing.\n' >&2
+ exit 1
+ fi
+ else
+ package_spec="@plannotator/artifact-server-opencode@$version"
+ fi
+ npm install --global --prefix "$prefix" "$package_spec" tsx@4.23.12
+ adapter_entry="$prefix/lib/node_modules/@plannotator/artifact-server-opencode/index.ts"
+ test -f "$adapter_entry"
+ ARTIFACTSERVER_ADAPTER_ENTRY="$adapter_entry" node \
+ --import "$prefix/lib/node_modules/tsx/dist/loader.mjs" \
+ --input-type=module <<'NODE'
+ import {pathToFileURL} from "node:url";
+ const loaded = await import(pathToFileURL(process.env.ARTIFACTSERVER_ADAPTER_ENTRY));
+ if (typeof loaded.ArtifactServerBridge !== "function") {
+ throw new Error("The installed OpenCode adapter did not expose its plugin entry.");
+ }
+ NODE
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5557f98..7d9b1ed 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -403,15 +403,34 @@ jobs:
printf '%s\n' \
'::notice::npm provenance is deferred until the source repository is public.'
fi
- npm publish \
- "release/public/plannotator-artifact-server-pi-$ARTIFACTSERVER_VERSION.tgz" \
- "${provenance_args[@]}" --access public
- npm publish \
- "release/public/plannotator-artifact-server-opencode-$ARTIFACTSERVER_VERSION.tgz" \
- "${provenance_args[@]}" --access public
- npm publish \
- "release/public/plannotator-artifact-server-claude-channel-$ARTIFACTSERVER_VERSION.tgz" \
- "${provenance_args[@]}" --access public
+ publish_adapter() {
+ package_name=$1
+ archive=$2
+ local_integrity=$(node --input-type=module -e \
+ 'import {createHash} from "node:crypto"; import {readFileSync} from "node:fs"; const bytes = readFileSync(process.argv[1]); process.stdout.write(`sha512-${createHash("sha512").update(bytes).digest("base64")}`);' \
+ "$archive")
+ if remote_integrity=$(npm view \
+ "$package_name@$ARTIFACTSERVER_VERSION" dist.integrity 2>/dev/null); then
+ if [[ "$remote_integrity" != "$local_integrity" ]]; then
+ printf 'npm already contains different bytes for %s@%s.\n' \
+ "$package_name" "$ARTIFACTSERVER_VERSION" >&2
+ exit 1
+ fi
+ printf '::notice::%s@%s already contains the verified release bytes.\n' \
+ "$package_name" "$ARTIFACTSERVER_VERSION"
+ return
+ fi
+ npm publish "$archive" "${provenance_args[@]}" --access public
+ }
+ publish_adapter \
+ @plannotator/artifact-server-pi \
+ "release/public/plannotator-artifact-server-pi-$ARTIFACTSERVER_VERSION.tgz"
+ publish_adapter \
+ @plannotator/artifact-server-opencode \
+ "release/public/plannotator-artifact-server-opencode-$ARTIFACTSERVER_VERSION.tgz"
+ publish_adapter \
+ @plannotator/artifact-server-claude-channel \
+ "release/public/plannotator-artifact-server-claude-channel-$ARTIFACTSERVER_VERSION.tgz"
release:
name: Create GitHub prerelease
@@ -420,6 +439,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 8
permissions:
+ actions: write
contents: write
steps:
- name: Download downloadable release assets
@@ -439,3 +459,12 @@ jobs:
--prerelease
--generate-notes
--title "Artifact Server $ARTIFACTSERVER_TAG"
+ - name: Dispatch published-install verification
+ env:
+ ARTIFACTSERVER_TAG: ${{ needs.verify.outputs.tag }}
+ GH_TOKEN: ${{ github.token }}
+ run: >-
+ gh workflow run install-verify.yml
+ --repo "$GITHUB_REPOSITORY"
+ --ref main
+ --field release_tag="$ARTIFACTSERVER_TAG"
diff --git a/src/cli/open-management-command.ts b/src/cli/open-management-command.ts
index d33b11e..d2f3e76 100644
--- a/src/cli/open-management-command.ts
+++ b/src/cli/open-management-command.ts
@@ -27,6 +27,8 @@ export function configureOpenManagementCommand(
currentCliInvocation(),
)).origin;
await openSystemBrowser(new URL(origin), process.env);
- process.stdout.write("Opened the local Artifact Server application.\n");
+ process.stdout.write(
+ `Opened the local Artifact Server application: ${origin}\n`,
+ );
});
}
diff --git a/tests/cli/local-cli.test.ts b/tests/cli/local-cli.test.ts
index 617a3fc..7138140 100644
--- a/tests/cli/local-cli.test.ts
+++ b/tests/cli/local-cli.test.ts
@@ -77,7 +77,7 @@ describe("local Artifact Server CLI", () => {
);
expect(result.exitCode).toBe(0);
expect(result.output).toContain(
- "Opened the local Artifact Server application.",
+ "Opened the local Artifact Server application:",
);
const browserCredential = (await readFile(
path.join(dataDirectory, "local-browser-token"),
@@ -90,6 +90,7 @@ describe("local Artifact Server CLI", () => {
const serviceRecord = managedServiceRecordSchema.parse(JSON.parse(
await readFile(path.join(dataDirectory, "local-service.json"), "utf8"),
));
+ expect(result.output).toContain(serviceRecord.origin);
servicePid = serviceRecord.pid;
expect((await fetch(new URL("/health", serviceRecord.origin))).status).toBe(200);
expect((await fetch(loginUrl)).status).toBe(200);