Skip to content

Commit 691b7c3

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.
1 parent 1d22641 commit 691b7c3

File tree

7 files changed

+320
-2
lines changed

7 files changed

+320
-2
lines changed

.github/workflows/release.yml

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

README.md

Lines changed: 5 additions & 1 deletion
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,6 +13,7 @@ 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

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();
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+
}

action-template/action.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Create signed commits out of local commits or a set of changed files.
2+
3+
description: |
4+
This GitHub Action was built specifically to simplify creating signed and verified commits on GitHub.
5+
6+
The created commits will be signed, and committer and author attribution will be the owner of the
7+
token that was used to create the commit. This is part of the GitHub API and cannot be changed.
8+
However, the original commit author and message will be retained as a "Co-authored-by" trailer and
9+
the message body, respectively.
10+
inputs:
11+
token:
12+
description: 'GitHub token'
13+
required: true
14+
default: ${{ github.token }}
15+
target:
16+
description: 'Target owner/repository'
17+
required: true
18+
default: ${{ github.repository }}
19+
branch:
20+
description: 'Target branch name'
21+
required: true
22+
command:
23+
description: 'Command to run. One of "commit" or "push"'
24+
required: true
25+
commits:
26+
description: 'For push, the list of commit hashes to push, oldest first'
27+
files:
28+
description: 'For commit, the list of files to include in the commit'
29+
force:
30+
description: 'For commit, set to true to support file deletion'
31+
default: false
32+
author:
33+
description: 'For commit, the commit author'
34+
default: 'github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>'
35+
message:
36+
description: 'For commit, the commit message'
37+
38+
outputs:
39+
pushed_sha:
40+
description: 'Commit hash of the last commit created'
41+
42+
runs:
43+
using: 'node20'
44+
main: 'action.js'

cmd_commit.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ func (c *CommitCmd) Run() error {
6262
rootfs := os.DirFS(".")
6363

6464
for _, path := range c.Files {
65+
path = strings.TrimPrefix(path, "./")
66+
6567
fp, err := rootfs.Open(path)
6668
if errors.Is(err, fs.ErrNotExist) {
6769
if !c.Force {

version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package main
22

3-
const VERSION = "0.3.0"
3+
const VERSION = "0.4.0"

0 commit comments

Comments
 (0)