Skip to content

Commit 7c68e77

Browse files
jedudenclaude
andauthored
Add GitHub Actions distribution channel (#623)
* Add GitHub Actions distribution channel Ship mdsmith as a composite GitHub Action via a root action.yml. It downloads the checksum-verified release binary for the runner's OS and architecture, puts mdsmith on PATH, and optionally runs a command from its args input (version, args, and working-directory inputs; a version output). Register it as a release channel under docs/development/release-channels/. Like the WinGet channel it is unlisted until a tagged release ships the action and a floating v0 tag resolves uses: jeduden/mdsmith@v0: the install-guide table excludes it by glob and sync-channels keeps it out of the website install picker. mdsmith fix propagated the catalog summary into CLAUDE.md and its generated mirrors. Add a cross-platform smoke-test workflow that installs a pinned past release on ubuntu, macos, and windows runners, exercising the OS/arch mapping, checksum verification (sha256sum with a shasum fallback for macOS), and PATH wiring. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC * Fix action checksum verification on Windows runners Windows runners return $RUNNER_TEMP as a backslash path. GNU coreutils sha256sum escapes a backslash in the checksummed file path by prefixing the hash with one, so the computed hash gained a leading backslash and never matched the clean hash from checksums.txt. The cross-platform smoke test caught it: ubuntu and macos passed, windows failed. Normalize the runner temp path's backslashes to forward slashes before building any path from it. The forward-slash form also resolves cleanly for the GITHUB_PATH write the bash steps read back. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC * Document SHA-pinning as the GitHub Action best practice Lead the channel doc's usage example and guidance with pinning uses: to a full-length commit SHA rather than a version tag, matching what GitHub recommends and what this repository already does for every third-party action. Clarify the two distinct controls: the SHA pins the action ref, and the action's checksum verification pins the release binary it downloads, so the version: input stays a release tag by design. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC * Note mdsmith's immutable releases in the pinning guidance Distinguish mdsmith's immutable release tags from the floating @v0 convenience tag: a release-tag pin like @v0.41.0 is a safe, reproducible alternative, while only @v0 moves by design. The commit SHA stays the strongest pin because it can never move. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC * Publish the GitHub Action to the Marketplace and list it Model the GitHub Actions channel as a push channel so it surfaces in the "Available on" strip on mdsmith.dev (the strip filters to mechanism: push) alongside the install picker and table: - Switch the channel to mechanism: push with registry github.com/marketplace, credential GITHUB_TOKEN, job release, and a Marketplace channel URL; drop unlisted and the install-table glob exclusion. The release job drafts a release that carries action.yml; publishing it adds/updates the Marketplace listing (one-time Developer Agreement acceptance required). - Name the action "mdsmith" for a clean Marketplace slug. - Add a marketplace-major-tag workflow that, on release publish, moves the major-version tag (v0) onto the release so uses: jeduden/mdsmith@v0 tracks the latest 0.x build. It pushes via a masked http.extraheader with persist-credentials: false, matching the benchmark-publish pattern. Regenerate channels.yaml and the catalogs; tighten one line of release.md prose so its push-channels table stays within the file-length budget. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC * Keep the Marketplace channel unlisted until the first publish The Marketplace listing and @v0 only resolve after the one-time Developer Agreement acceptance and the first release that ships action.yml. Listing it before then would show a dead "Available on" link, so hide it until live (the WinGet pattern): - Set unlisted: true on the channel; sync-channels keeps it out of the website install picker and the "Available on" strip. - Re-add the install-guide table glob exclusion, and exclude it from the release-pipeline push table too (Marketplace publish is a manual checkbox, not a credentialed automated job). - Add Operational Checklist item 8 in release.md: accept the Marketplace agreement on the first action release. The channel doc carries the full one-time steps and the flip-when-live instructions. The action.yml, the marketplace-major-tag workflow, and the docs stay in place; only the user-facing listings wait for the first publish. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC * Apply code-review fixes; drop the unworkable @v0 floating tag /code-review (xhigh) surfaced: - marketplace-major-tag.yml never fires: a GITHUB_TOKEN-published release does not trigger workflows (GitHub anti-recursion), so @v0 never moved. Drop the @v0 floating tag and delete the workflow; recommend commit-SHA (strongest) or immutable release-tag pinning, which need no automation. The release job's publish step is what updates the Marketplace listing, so job: release / credential: GITHUB_TOKEN are now accurate. - action.yml checksum: the `-z "$expected"` guard was dead under set -euo pipefail (the grep | head pipeline tripped pipefail before the empty check), so a missing asset aborted with no diagnostic. Switch to an awk match that exits 0 on no match, also tolerating binary-mode (*) checksums and dropping the SIGPIPE-prone head. - action.yml: multiline-safe GITHUB_OUTPUT write; correct the args comment (the shell also glob-expands, not only whitespace-splits). - action-smoke: assert the version output contains the pinned version rather than only checking it is non-empty. mdsmith check clean, channels.yaml drift-free, zizmor clean. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC * Second code-review pass: heredoc-on-failure and doc version coherence - action.yml: capture `mdsmith version` into a variable before the GITHUB_OUTPUT heredoc. A non-zero version exit now aborts cleanly (set -e) instead of leaving a dangling, unterminated `version<<EOF` that corrupts GITHUB_OUTPUT with "Matching delimiter not found". - github-actions.md: stop presenting the concrete v0.41.0 as the action ref (that release predates action.yml). Use a vX.Y.Z placeholder, decouple the binary `version:` input (default latest) from the pinned action ref, and note to set `command` to the first action-bearing release tag at go-live. https://claude.ai/code/session_01TKgJTS4UcFPqtdwkxYzTUC --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac65a9b commit 7c68e77

8 files changed

Lines changed: 284 additions & 8 deletions

File tree

.github/copilot-instructions.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ row: "- [{summary}](../{filename})"
5454
- [Rebase, CI monitoring, and review comment resolution.](../docs/development/pr-fixup-workflow.md)
5555
- [The `jeduden/asdf-mdsmith` plugin installs the checksum-verified prebuilt binary; the short form awaits the asdf-plugins registry entry.](../docs/development/release-channels/asdf.md)
5656
- [A single-file `.flatpak` bundle built in CI from the x86_64 Linux release binary and attached to each GitHub release, installed by file with host filesystem access for the linter.](../docs/development/release-channels/flatpak.md)
57+
- [A composite action at the repository root downloads the checksum-verified release binary for the runner's OS and architecture and puts `mdsmith` on `PATH`; published to the GitHub Marketplace and pinned by commit SHA or release tag.](../docs/development/release-channels/github-actions.md)
5758
- [Per-platform mdsmith binaries plus the .vsix, the checksum file, and a Sigstore signature, attached to a tag-named release.](../docs/development/release-channels/github-releases.md)
5859
- [`go install` compiles mdsmith from the tagged module source with the host Go 1.25+ toolchain; no prebuilt binary is downloaded.](../docs/development/release-channels/go.md)
5960
- [The `jeduden/homebrew-mdsmith` tap installs the checksum-verified prebuilt binary for macOS or Linux on Intel or arm64.](../docs/development/release-channels/homebrew.md)

.github/workflows/action-smoke.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Action smoke test
2+
3+
# Exercise the root composite action (action.yml) end-to-end on every
4+
# runner OS it claims to support, so a break in the OS/arch mapping, the
5+
# checksum verification, or the PATH wiring fails the PR rather than a
6+
# downstream user's workflow. It installs a pinned past release, so the
7+
# test is deterministic and does not depend on this PR being released yet.
8+
on:
9+
push:
10+
branches: [main]
11+
paths:
12+
- "action.yml"
13+
- ".github/workflows/action-smoke.yml"
14+
pull_request:
15+
branches: [main]
16+
paths:
17+
- "action.yml"
18+
- ".github/workflows/action-smoke.yml"
19+
20+
permissions:
21+
contents: read
22+
23+
jobs:
24+
smoke:
25+
name: Install and run (${{ matrix.os }})
26+
runs-on: ${{ matrix.os }}
27+
strategy:
28+
fail-fast: false
29+
matrix:
30+
os: [ubuntu-latest, macos-latest, windows-latest]
31+
steps:
32+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
33+
with:
34+
persist-credentials: false
35+
36+
- id: mdsmith
37+
uses: ./
38+
with:
39+
# A real, immutable past release so the download is deterministic.
40+
version: v0.41.0
41+
args: version
42+
43+
- name: Verify mdsmith stayed on PATH and reported the pinned version
44+
shell: bash
45+
env:
46+
GOT_VERSION: ${{ steps.mdsmith.outputs.version }}
47+
run: |
48+
mdsmith version
49+
case "$GOT_VERSION" in
50+
*0.41.0*) echo "version output OK: $GOT_VERSION" ;;
51+
*) echo "::error::unexpected version output: $GOT_VERSION"; exit 1 ;;
52+
esac

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ row: "- [{summary}]({filename})"
6060
- [Rebase, CI monitoring, and review comment resolution.](docs/development/pr-fixup-workflow.md)
6161
- [The `jeduden/asdf-mdsmith` plugin installs the checksum-verified prebuilt binary; the short form awaits the asdf-plugins registry entry.](docs/development/release-channels/asdf.md)
6262
- [A single-file `.flatpak` bundle built in CI from the x86_64 Linux release binary and attached to each GitHub release, installed by file with host filesystem access for the linter.](docs/development/release-channels/flatpak.md)
63+
- [A composite action at the repository root downloads the checksum-verified release binary for the runner's OS and architecture and puts `mdsmith` on `PATH`; published to the GitHub Marketplace and pinned by commit SHA or release tag.](docs/development/release-channels/github-actions.md)
6364
- [Per-platform mdsmith binaries plus the .vsix, the checksum file, and a Sigstore signature, attached to a tag-named release.](docs/development/release-channels/github-releases.md)
6465
- [`go install` compiles mdsmith from the tagged module source with the host Go 1.25+ toolchain; no prebuilt binary is downloaded.](docs/development/release-channels/go.md)
6566
- [The `jeduden/homebrew-mdsmith` tap installs the checksum-verified prebuilt binary for macOS or Linux on Intel or arm64.](docs/development/release-channels/homebrew.md)

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ row: "- [{summary}]({filename})"
4646
- [Rebase, CI monitoring, and review comment resolution.](docs/development/pr-fixup-workflow.md)
4747
- [The `jeduden/asdf-mdsmith` plugin installs the checksum-verified prebuilt binary; the short form awaits the asdf-plugins registry entry.](docs/development/release-channels/asdf.md)
4848
- [A single-file `.flatpak` bundle built in CI from the x86_64 Linux release binary and attached to each GitHub release, installed by file with host filesystem access for the linter.](docs/development/release-channels/flatpak.md)
49+
- [A composite action at the repository root downloads the checksum-verified release binary for the runner's OS and architecture and puts `mdsmith` on `PATH`; published to the GitHub Marketplace and pinned by commit SHA or release tag.](docs/development/release-channels/github-actions.md)
4950
- [Per-platform mdsmith binaries plus the .vsix, the checksum file, and a Sigstore signature, attached to a tag-named release.](docs/development/release-channels/github-releases.md)
5051
- [`go install` compiles mdsmith from the tagged module source with the host Go 1.25+ toolchain; no prebuilt binary is downloaded.](docs/development/release-channels/go.md)
5152
- [The `jeduden/homebrew-mdsmith` tap installs the checksum-verified prebuilt binary for macOS or Linux on Intel or arm64.](docs/development/release-channels/homebrew.md)

action.yml

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
name: mdsmith
2+
description: >-
3+
Install the mdsmith Markdown linter from a checksum-verified GitHub release
4+
binary for the runner's OS and architecture, put it on PATH, and optionally
5+
run a mdsmith command.
6+
author: jeduden
7+
branding:
8+
icon: check-square
9+
color: purple
10+
11+
inputs:
12+
version:
13+
description: >-
14+
mdsmith release to install: a release tag such as "v0.41.0", or "latest"
15+
to fetch the most recent release.
16+
required: false
17+
default: latest
18+
args:
19+
description: >-
20+
Arguments passed to mdsmith after install, split on whitespace
21+
(for example "check ."). Leave empty to only put mdsmith on PATH.
22+
required: false
23+
default: ""
24+
working-directory:
25+
description: Directory the mdsmith command runs in when "args" is set.
26+
required: false
27+
default: "."
28+
29+
outputs:
30+
version:
31+
description: The version string reported by the installed mdsmith binary.
32+
value: ${{ steps.install.outputs.version }}
33+
34+
runs:
35+
using: composite
36+
steps:
37+
- id: install
38+
shell: bash
39+
# Inputs flow in through env, never interpolated into the script body,
40+
# so a crafted input cannot inject shell commands.
41+
env:
42+
MDSMITH_VERSION: ${{ inputs.version }}
43+
# The binary is downloaded over HTTPS and SHA256-verified against the
44+
# release checksums.txt before its directory is appended to PATH, so the
45+
# github-path write is safe to ignore.
46+
run: | # zizmor: ignore[github-env]
47+
set -euo pipefail
48+
49+
# Map the runner OS/arch onto the published release-asset name.
50+
case "$RUNNER_OS" in
51+
Linux) os=linux ; ext="" ;;
52+
macOS) os=darwin ; ext="" ;;
53+
Windows) os=windows; ext=".exe" ;;
54+
*) echo "::error::unsupported runner OS: $RUNNER_OS"; exit 1 ;;
55+
esac
56+
case "$RUNNER_ARCH" in
57+
X64) arch=amd64 ;;
58+
ARM64) arch=arm64 ;;
59+
*) echo "::error::unsupported runner arch: $RUNNER_ARCH"; exit 1 ;;
60+
esac
61+
if [ "$os" = windows ] && [ "$arch" != amd64 ]; then
62+
echo "::error::mdsmith ships a windows-amd64 build only, not windows-$arch"
63+
exit 1
64+
fi
65+
asset="mdsmith-${os}-${arch}${ext}"
66+
67+
if [ "$MDSMITH_VERSION" = latest ]; then
68+
base="https://github.com/jeduden/mdsmith/releases/latest/download"
69+
else
70+
base="https://github.com/jeduden/mdsmith/releases/download/${MDSMITH_VERSION}"
71+
fi
72+
73+
# Normalize the runner temp path's backslashes to forward slashes.
74+
# Windows runners hand back a backslash path, and GNU coreutils
75+
# escapes a backslash in a checksummed path by prefixing the hash
76+
# with one — which would break the comparison below. The
77+
# forward-slash form also resolves cleanly for the PATH write.
78+
dest="${RUNNER_TEMP//\\//}/mdsmith-bin"
79+
mkdir -p "$dest"
80+
curl -fsSL --retry 3 --retry-delay 2 "${base}/${asset}" -o "${dest}/${asset}"
81+
curl -fsSL --retry 3 --retry-delay 2 "${base}/checksums.txt" -o "${dest}/checksums.txt"
82+
83+
# Verify the download against the release checksums file. macOS runners
84+
# have shasum but not GNU sha256sum, so fall back to it.
85+
# Pull the expected hash for our asset. Match the filename as the last
86+
# field, tolerating the binary-mode "*" prefix, and stop at the first
87+
# match. awk exits 0 even on no match, so the empty check below still
88+
# runs — a grep | head pipeline would trip pipefail + set -e first and
89+
# skip the friendly error.
90+
expected="$(awk -v a="$asset" '{n = $NF; sub(/^\*/, "", n); if (n == a) {print $1; exit}}' "${dest}/checksums.txt")"
91+
if [ -z "$expected" ]; then
92+
echo "::error::${asset} not listed in checksums.txt"
93+
exit 1
94+
fi
95+
if command -v sha256sum >/dev/null 2>&1; then
96+
actual="$(sha256sum "${dest}/${asset}" | awk '{print $1}')"
97+
else
98+
actual="$(shasum -a 256 "${dest}/${asset}" | awk '{print $1}')"
99+
fi
100+
if [ "$expected" != "$actual" ]; then
101+
echo "::error::checksum mismatch for ${asset}: expected ${expected}, got ${actual}"
102+
exit 1
103+
fi
104+
105+
bin="mdsmith${ext}"
106+
mv "${dest}/${asset}" "${dest}/${bin}"
107+
chmod +x "${dest}/${bin}"
108+
echo "$dest" >> "$GITHUB_PATH"
109+
# Capture first so a non-zero `mdsmith version` aborts here (set -e)
110+
# rather than leaving a half-written heredoc in GITHUB_OUTPUT. The
111+
# heredoc keeps the write correct even if version output ever grows
112+
# past the single line it prints today.
113+
ver="$("${dest}/${bin}" version)"
114+
{
115+
echo "version<<__MDS_EOF__"
116+
echo "$ver"
117+
echo "__MDS_EOF__"
118+
} >> "$GITHUB_OUTPUT"
119+
120+
- if: ${{ inputs.args != '' }}
121+
shell: bash
122+
working-directory: ${{ inputs.working-directory }}
123+
env:
124+
MDSMITH_ARGS: ${{ inputs.args }}
125+
# MDSMITH_ARGS is intentionally unquoted so the shell word-splits it
126+
# into separate arguments (and expands any globs), e.g. "check .".
127+
run: mdsmith $MDSMITH_ARGS
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
title: GitHub Actions
3+
summary: >-
4+
A composite action at the repository root downloads the
5+
checksum-verified release binary for the runner's OS and
6+
architecture and puts `mdsmith` on `PATH`; published to
7+
the GitHub Marketplace and pinned by commit SHA or
8+
release tag.
9+
mechanism: push
10+
artifact: cli
11+
command: "uses: jeduden/mdsmith@vX.Y.Z"
12+
audience: Linting Markdown inside GitHub Actions CI
13+
platforms: [linux, macos, windows]
14+
registry: github.com/marketplace
15+
credential: GITHUB_TOKEN
16+
job: release
17+
channelurl: https://github.com/marketplace/actions/mdsmith
18+
weight: 15
19+
unlisted: true
20+
---
21+
# GitHub Actions
22+
23+
Release page: <https://github.com/marketplace/actions/mdsmith>
24+
25+
The repository root carries an `action.yml`, so a workflow
26+
step runs mdsmith with:
27+
28+
```yaml
29+
- uses: jeduden/mdsmith@<commit-sha> # vX.Y.Z
30+
with:
31+
version: latest # which mdsmith release to install (a tag, or latest)
32+
args: check . # omit to only put mdsmith on PATH
33+
```
34+
35+
The composite action reads `$RUNNER_OS` and
36+
`$RUNNER_ARCH`. It maps them to the matching release
37+
asset — `mdsmith-linux-amd64`, `mdsmith-darwin-arm64`,
38+
`mdsmith-windows-amd64.exe`, and the rest. It downloads
39+
that asset over HTTPS. Then it verifies the SHA-256
40+
against the release's `checksums.txt` before it adds the
41+
binary to `PATH`.
42+
43+
macOS runners fall back to `shasum -a 256`, since they
44+
ship no GNU `sha256sum`. Windows runners get the one
45+
`windows-amd64` build. Any other Windows architecture
46+
fails with a clear error, not a 404.
47+
48+
Three inputs drive it. `version` selects the release —
49+
`latest` (the default) or a tag such as `v0.41.0`. `args`,
50+
when non-empty, is split on whitespace and passed to
51+
`mdsmith`; an empty `args` only installs the binary so a
52+
later step can call it. `working-directory` sets the
53+
directory the `args` command runs in. The action exposes
54+
one output, `version`, the string `mdsmith version` prints.
55+
56+
Pin `uses:` to a full-length commit SHA for the strongest
57+
guarantee: a SHA can never move. mdsmith publishes
58+
immutable releases, so a release-tag pin like `@vX.Y.Z`
59+
(any release that ships `action.yml`) is a safe,
60+
reproducible alternative. GitHub recommends the SHA form,
61+
and this repository uses it for every third-party action.
62+
Keep the version in a trailing comment, as `# vX.Y.Z`
63+
above.
64+
65+
The action still verifies the downloaded binary's SHA-256
66+
against the release `checksums.txt`. So the action and the
67+
binary it fetches are both pinned by digest, not by a
68+
movable name.
69+
70+
The action publishes to the GitHub Marketplace through a
71+
release. The `release` job drafts a release at the tagged
72+
commit, which carries this `action.yml`. Publishing that
73+
draft adds or updates the Marketplace listing.
74+
75+
The first listing is a manual, one-time step. The
76+
maintainer accepts the Marketplace Developer Agreement and
77+
enables it on a release. A unique action `name` and
78+
`branding` are required; `action.yml` sets both.
79+
80+
The first release that ships this `action.yml` makes the
81+
Marketplace listing and its release tags resolve. Until
82+
then, pin to a commit SHA on `main`.
83+
84+
You can also skip the action entirely. Run the release
85+
binary in a `run:` step. That repeats by hand the download
86+
and verify steps the action automates.
87+
88+
While the listing is pending, the channel stays hidden. It
89+
sets `unlisted: true`. `sync-channels` then keeps it out of
90+
the install picker and the "Available on" strip. The
91+
install-guide and release-pipeline tables exclude it by
92+
glob. Once the listing resolves, flip `unlisted`, drop both
93+
glob exclusions, and set `command` to that release's tag.

docs/development/release.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,14 @@ summary: >-
1212
---
1313
# Release Pipeline
1414

15-
`.github/workflows/release.yml` publishes mdsmith to
16-
every channel below. Release-time secrets travel as
17-
short-lived OIDC tokens. The remaining long-lived
18-
PATs are gated by the `release` GitHub environment.
19-
Each channel has its own file under
20-
`release-channels/`; the catalog re-renders on
21-
`mdsmith fix`.
15+
`.github/workflows/release.yml` publishes mdsmith to every channel
16+
below. Release-time secrets travel as short-lived OIDC tokens. The
17+
remaining long-lived PATs are gated by the `release` GitHub
18+
environment. Each channel has its own file under `release-channels/`;
19+
the catalog re-renders on `mdsmith fix`.
2220

2321
<?catalog
24-
glob: ["release-channels/*.md", "!release-channels/proto.md"]
22+
glob: ["release-channels/*.md", "!release-channels/{proto,github-actions}.md"]
2523
where: 'mechanism: "push"'
2624
sort: title
2725
header: |
@@ -379,6 +377,8 @@ place.
379377
set. With `gate` in every credential job's `needs:`,
380378
it is the only release gate, and the workflow (not a
381379
maintainer) creates the tag.
380+
8. [ ] Accept the GitHub Marketplace agreement on the
381+
first action release (see `release-channels/github-actions.md`).
382382

383383
## Verifying a Released Artifact
384384

docs/guides/install.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ glob:
4343
- "../development/release-channels/*.md"
4444
- "!../development/release-channels/proto.md"
4545
- "!../development/release-channels/winget.md"
46+
- "!../development/release-channels/github-actions.md"
4647
where: 'artifact: "cli"'
4748
sort: numeric:weight
4849
header: |

0 commit comments

Comments
 (0)