Skip to content

Commit 379119b

Browse files
authored
ci: set up an action release pipeline (#17)
This patch sets up an action release pipeline on merges to main which resembles the gitlab release pipeline. On a merge to main, the workflow kicks in and (assuming everything builds and tests well), updates the `action` branch with the contents of `action-template/`. The workflow leverages the bundled action, which ensures that, at the very least, we cannot produce a tagged release of the action that cannot at least release itself. Note that the action branch contains the binaries built earlier in the workflow to avoid having to run a composite action. Co-authored-by: Alex Vidal <alex.vidal@datadoghq.com>
1 parent 68fb327 commit 379119b

File tree

7 files changed

+454
-14
lines changed

7 files changed

+454
-14
lines changed

.github/workflows/release.yml

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Runs on pushes to main, manages the `action` branch and `action/` tags family.
2+
name: Build and release action
3+
4+
permissions:
5+
contents: write
6+
7+
on:
8+
push:
9+
branches:
10+
- 'main'
11+
12+
jobs:
13+
release:
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
18+
with:
19+
fetch-depth: 0 # needed to make sure we get all tags
20+
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
21+
with:
22+
go-version: '1.24'
23+
24+
- run: go test -v .
25+
26+
- name: build
27+
run: |
28+
GOOS=linux GOARCH=amd64 go build -buildvcs=false -o ./dist/commit-headless-linux-amd64 .
29+
GOOS=linux GOARCH=arm64 go build -buildvcs=false -o ./dist/commit-headless-linux-arm64 .
30+
31+
# TODO: Not sure how to determine the current os/arch to select one of the above binaries
32+
# so we're just going to build another one
33+
go build -buildvcs=false -o ./dist/commit-headless .
34+
./dist/commit-headless version | awk '{print $3}' > ./dist/VERSION.txt
35+
echo "Current version: $(cat ./dist/VERSION.txt)"
36+
37+
- name: create action branch commit
38+
id: create-commit
39+
run: |
40+
41+
# Copy the new assets to a temporary location that we can recover later
42+
cp -R dist /tmp/release-assets
43+
44+
git switch action
45+
46+
# Remove everything except the git directory
47+
find . -not -path "./.git" -not -path '.' -maxdepth 1 -exec rm -rf {} +
48+
49+
# Bring back the release assets
50+
mv /tmp/release-assets dist
51+
52+
# "Restore" the contents of action-template from the previous ref
53+
git restore --source "${{ github.sha }}" action-template/
54+
55+
# Copy the contents of action-template to the top of the repository
56+
cp action-template/* . && rm -rf action-template
57+
58+
# Replace the VERSION in README.md
59+
sed -i "s/%%VERSION%%/$(cat dist/VERSION.txt)/g" README.md
60+
61+
git add --all
62+
63+
echo "Changes to commit.."
64+
git status
65+
66+
# Create a commit
67+
# TODO: A merge should have the PR number in the commit headline, if we use the original
68+
# commit message it should back-link
69+
git config user.name "github-actions[bot]"
70+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com>"
71+
git commit \
72+
--message="Update action from ${{ github.sha }}" \
73+
--allow-empty # sometimes we have nothing to change, so this ensures we can still commit
74+
75+
REF=$(git rev-parse HEAD)
76+
echo "sha=${REF}" >> $GITHUB_OUTPUT
77+
echo "Created commit ${REF}"
78+
79+
- name: push commits
80+
id: push-commits
81+
uses: ./ # use the action defined in the action branch
82+
with:
83+
branch: action
84+
command: push
85+
commits: ${{ steps.create-commit.outputs.sha }}
86+
87+
- name: check release tag
88+
id: check-tag
89+
run: |
90+
TAG="action/v$(cat ./dist/VERSION.txt)"
91+
if git show-ref --tags --verify --quiet "refs/tags/${TAG}"; then
92+
echo "Release tag ${TAG} already exists. Not releasing."
93+
exit 1
94+
fi
95+
echo "tag=${TAG}" >> $GITHUB_OUTPUT
96+
97+
- name: make release
98+
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
99+
with:
100+
script: |
101+
github.rest.git.createRef({
102+
owner: context.repo.owner,
103+
repo: context.repo.repo,
104+
ref: 'refs/tags/${{ steps.check-tag.outputs.tag }}',
105+
sha: '${{ steps.push-commits.outputs.pushed_ref }}'
106+
});

README.md

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# commit-headless
22

3-
A binary tool and GitHub action for creating signed commits from headless workflows
3+
A binary tool and GitHub Action for creating signed commits from headless workflows
4+
5+
For the Action, please see [the action branch][action-branch] and the associated `action/`
6+
release tags.
47

58
`commit-headless` is focused on turning local commits (or dirty files) into signed commits on the
69
remote. It does this via the GitHub GraphQL API, more specifically the [createCommitOnBranch][mutation]
@@ -10,31 +13,64 @@ When this API is used with a GitHub App token, the resulting commit will be sign
1013
GitHub on behalf of the application.
1114

1215
[mutation]: https://docs.github.com/en/graphql/reference/mutations#createcommitonbranch
16+
[action-branch]: https://github.com/DataDog/commit-headless/tree/action
1317

1418
## Usage
1519

16-
Currently, there is one command: `commit-headless push`. It takes a target owner/repository and
17-
remote branch name, as well as a list of commit hashes as arguments *or* a list of commit hashes *in
18-
reverse chronological order (newest first)* on standard input.
20+
There are two ways to create signed headless commits with this tool: `push` and `commit`.
21+
22+
Both of these commands take a target owner/repository (eg, `--target/-T DataDog/commit-headless`)
23+
and remote branch name (eg, `--branch bot-branch`) as required flags and expect to find a GitHub
24+
token in one of the following environment variables:
25+
26+
- HEADLESS_TOKEN
27+
- GITHUB_TOKEN
28+
- GH_TOKEN
29+
30+
In normal usage, `commit-headless` will print *only* the reference to the last commit created on the
31+
remote, allowing this to easily be captured in a script.
32+
33+
More on the specifics for each command below. See also: `commit-headless <command> --help`
34+
35+
### commit-headless push
36+
37+
In addition to the required target and branch flags, the `push` command expects a list of commit
38+
hashes as arguments *or* a list of commit hashes *in reverse chronological order (newest first)*
39+
on standard input.
1940

2041
It will iterate over the supplied commits, extract the set of changed files and commit message, then
2142
craft new *remote* commits corresponding to each local commit.
2243

2344
The remote commit will have the original commit message, with "Co-authored-by" trailer for the
24-
original commit message. This is because commits created using the GraphQL API do not support
25-
setting the author or committer (they are inferred from the token owner), so adding a
26-
"Co-authored-by" trailer allows the commits to carry attribution to the original (bot) committer.
27-
28-
In normal usage, `commit-headless` will print *only* the reference to the last commit created on the
29-
remote, allowing this to easily be captured in a script. For example output, see the later section.
45+
original commit author.
3046

3147
You can use `commit-headless push` via:
3248

33-
GH_TOKEN=xyz commit-headless push --target datadog/commit-headless --branch bot-branch-remote HASH1 HASH2 HASH3 ...
49+
commit-headless push [flags...] HASH1 HASH2 HASH3 ...
3450

3551
Or, using git log (note `--oneline`):
3652

37-
git log --oneline main.. | GH_TOKEN=xyz commit-headless push --target datadog/commit-headless --branch bot-branch-remote
53+
git log --oneline main.. | commit-headless push [flags...]
54+
55+
### commit-headless commit
56+
57+
This command is more geared for creating single commits at a time. It takes a list of files to
58+
commit changes to, and those files will either be updated/added or deleted in a single commit.
59+
60+
Note that you cannot delete a file without also adding `--force` for safety reasons.
61+
62+
Examples:
63+
64+
# Commit changes to these two files
65+
commit-headless commit [flags...] -- README.md .gitlab-ci.yml
66+
67+
# Remove a file, add another one, and commit
68+
rm file/i/do/not/want
69+
echo "hello" > hi-there.txt
70+
commit-headless commit [flags...] --force -- hi-there.txt file/i/do/not/want
71+
72+
# Commit a change with a custom message
73+
commit-headless commit [flags...] -m"ran a pipeline" -- output.txt
3874

3975
## Try it!
4076

@@ -88,13 +124,14 @@ Prerelease occurs automatically on a push to main, or can be manually triggered
88124
`release:build` job on any branch.
89125

90126
Additionally, on main, the `release:publish` job will run. This job takes the prerelease image and
91-
tags it for release.
127+
tags it for release, as well as produces a CI image with various other tools.
92128

93129
You can view all releases (and prereleases) with crane:
94130

95131
```
96132
$ crane ls registry.ddbuild.io/commit-headless-prerelease
97133
$ crane ls registry.ddbuild.io/commit-headless
134+
$ crane ls registry.ddbuild.io/commit-headless-ci-image
98135
```
99136

100137
Note that the final publish job will fail unless there was also a change to `version.go` to avoid

action-template/README.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# commit-headless action
2+
3+
NOTE: This branch contains only the action implementation of `commit-headless`. To view the source
4+
code, see the [main](https://github.com/DataDog/commit-headless/tree/main) branch.
5+
6+
This action uses `commit-headless` to support creating signed and verified remote commits from a
7+
GitHub action workflow.
8+
9+
For more details on how `commit-headless` works, check the main branch link above.
10+
11+
## Usage (commit-headless push)
12+
13+
If your workflow creates multiple commits and you want to push all of them, you can use
14+
`commit-headless push`:
15+
16+
```
17+
- name: Create commits
18+
id: create-commits
19+
run: |
20+
git config --global user.name "A U Thor"
21+
git config --global user.email "author@example.com"
22+
23+
echo "new file from my bot" >> bot.txt
24+
git add bot.txt && git commit -m"bot commit 1"
25+
26+
echo "another commit" >> bot.txt
27+
git add bot.txt && git commit -m"bot commit 2"
28+
29+
# List both commit hashes in reverse order, space separated
30+
echo "commits=\"$(git log "${{ github.sha }}".. --format='%H%x00' | tr '\n' ' ')\"" >> $GITHUB_OUTPUT
31+
32+
- name: Push commits
33+
uses: DataDog/commit-headless@action/v%%VERSION%%
34+
with:
35+
token: ${{ github.token }} # default
36+
target: ${{ github.repository }} # default
37+
branch: ${{ github.ref_name }}
38+
command: push
39+
commits: "${{ steps.create-commits.outputs.commits }}"
40+
```
41+
42+
## Usage (commit-headless commit)
43+
44+
Some workflows may just have a specific set of files that they change and just want to create a
45+
single commit out of them. For that, you can use `commit-headless commit`:
46+
47+
```
48+
- name: Change files
49+
id: change-files
50+
run: |
51+
echo "updating contents of bot.txt" >> bot.txt
52+
53+
date --rfc-3339=s >> timestamp
54+
55+
files="bot.txt timestamp"
56+
57+
# remove an old file if it exists
58+
# commit-headless commit will fail if you attempt to delete a file that doesn't exist on the
59+
# remote (enforced via the GitHub API)
60+
if [[ -f timestamp.old ]]; then
61+
rm timestamp.old
62+
files += " timestamp.old"
63+
fi
64+
65+
# Record the set of files we want to commit
66+
echo "files=\"${files}\"" >> $GITHUB_OUTPUT
67+
68+
- name: Create commit
69+
uses: DataDog/commit-headless@action/v%%VERSION%%
70+
with:
71+
token: ${{ github.token }} # default
72+
target: ${{ github.repository }} # default
73+
branch: ${{ github.ref_name }}
74+
author: "A U Thor <author@example.com>" # defaults to the github-actions bot account
75+
message: "a commit message"
76+
command: commit
77+
files: "${{ steps.create-commits.outputs.files }}"
78+
force: true # default false, needs to be true to allow deletion
79+
```

action-template/action.js

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
const childProcess = require('child_process')
2+
const crypto = require('crypto')
3+
const fs = require('fs')
4+
const os = require('os')
5+
const process = require('process')
6+
7+
function chooseBinary() {
8+
const platform = os.platform()
9+
const arch = os.arch()
10+
11+
if (platform === 'linux' && arch === 'x64') {
12+
return `dist/commit-headless-linux-amd64`
13+
}
14+
if (platform === 'linux' && arch === 'arm64') {
15+
return `dist/commit-headless-linux-arm64`
16+
}
17+
18+
console.error(`Unsupported platform (${platform}) and architecture (${arch})`)
19+
process.exit(1)
20+
}
21+
22+
function main() {
23+
const binary = chooseBinary()
24+
25+
const cmd = `${__dirname}/${binary}`
26+
27+
const env = { ...process.env };
28+
env.HEADLESS_TOKEN = process.env.INPUT_TOKEN;
29+
30+
const command = process.env.INPUT_COMMAND;
31+
32+
if (!["commit", "push"].includes(command)) {
33+
console.error(`Unknown command ${command}. Must be one of "commit" or "push".`);
34+
process.exit(1);
35+
}
36+
37+
let args = [
38+
command,
39+
"--target", process.env.INPUT_TARGET,
40+
"--branch", process.env.INPUT_BRANCH
41+
];
42+
43+
if (command === "push") {
44+
args.push(...process.env.INPUT_COMMITS.split(/\s+/));
45+
} else {
46+
const author = process.env["INPUT_AUTHOR"] || "";
47+
const message = process.env["INPUT_MESSAGE"] || "";
48+
if(author !== "") { args.push("--author", author) }
49+
if(message !== "") { args.push("--message", message) }
50+
51+
const force = process.env["INPUT_FORCE"] || "false"
52+
if(!["true", "false"].includes(force.toLowerCase())) {
53+
console.error(`Invalid value for force (${force}). Must be one of true or false.`);
54+
process.exit(1);
55+
}
56+
57+
if(force.toLowerCase() === "true") { args.push("--force") }
58+
59+
args.push(...process.env.INPUT_FILES.split(/\s+/));
60+
}
61+
62+
const child = childProcess.spawnSync(cmd, args, {
63+
env: env,
64+
stdio: ['ignore', 'pipe', 'inherit'],
65+
})
66+
67+
const exitCode = child.status
68+
if (typeof exitCode === 'number') {
69+
if(exitCode === 0) {
70+
const out = child.stdout.toString().trim();
71+
console.log(`Pushed reference ${out}`);
72+
73+
const delim = `delim_${crypto.randomUUID()}`;
74+
fs.appendFileSync(process.env.GITHUB_OUTPUT, `pushed_ref<<${delim}${os.EOL}${out}${os.EOL}${delim}`, { encoding: "utf8" });
75+
}
76+
77+
process.exit(exitCode)
78+
}
79+
process.exit(1)
80+
}
81+
82+
if (require.main === module) {
83+
main()
84+
}

0 commit comments

Comments
 (0)