Skip to content

Commit 6c6098a

Browse files
committed
feature: self-hosted support, v2 upload with retry, native release pipeline
1 parent 7c32b60 commit 6c6098a

8 files changed

Lines changed: 267 additions & 35 deletions

File tree

.githooks/pre-push

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bash
2+
# shellcheck shell=bash
3+
set -euo pipefail
4+
5+
# Rejects pushing non-standard tags. Release tags must be SemVer with a 'v' prefix:
6+
# vX.Y.Z (production, e.g. v0.5.0)
7+
# vX.Y.Z-<pre> (pre-release, e.g. v0.5.0-beta.1 — normally created by CI)
8+
# Examples REJECTED: X.Y.Z, X.Y, vX.Y, 1.0, v1.
9+
#
10+
# Enable once per clone: git config core.hooksPath .githooks
11+
12+
pattern='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'
13+
zero='0000000000000000000000000000000000000000'
14+
status=0
15+
16+
while read -r _local_ref _local_sha remote_ref remote_sha; do
17+
case "$remote_ref" in
18+
refs/tags/*)
19+
# Allow tag deletions (remote_sha stays, local sha is zero on delete is the other way;
20+
# we only validate creations/updates).
21+
[ "$remote_sha" = "$zero" ] && continue || true
22+
tag="${remote_ref#refs/tags/}"
23+
if ! printf '%s' "$tag" | grep -qE "$pattern"; then
24+
echo "✖ Refused to push tag '$tag'." >&2
25+
echo " Release tags must be SemVer with a 'v' prefix, e.g. v0.5.0 (or v0.5.0-beta.1)." >&2
26+
echo " Rejected forms: X.Y.Z, X.Y, vX.Y, bare numbers." >&2
27+
status=1
28+
fi
29+
;;
30+
esac
31+
done
32+
33+
exit $status

.github/workflows/release.yml

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: Release
2+
on:
3+
push:
4+
branches:
5+
- main
6+
- 'release/**'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
with:
17+
fetch-depth: 0
18+
19+
- uses: actions/setup-node@v4
20+
with:
21+
node-version: 20
22+
23+
- name: Install & build bundle
24+
run: |
25+
npm ci
26+
npm run package
27+
28+
- name: Verify dist/ is up to date
29+
run: |
30+
if [ -n "$(git status --porcelain dist)" ]; then
31+
echo "::error::dist/ is out of date — run 'npm run package' and commit it."
32+
git --no-pager diff --stat dist
33+
exit 1
34+
fi
35+
36+
- name: Release
37+
env:
38+
GH_TOKEN: ${{ github.token }}
39+
run: |
40+
set -euo pipefail
41+
BRANCH="${GITHUB_REF_NAME}"
42+
git config user.name "github-actions[bot]"
43+
git config user.email "github-actions[bot]@users.noreply.github.com"
44+
45+
# Version source of truth = latest stable tag vX.Y.Z (pushed manually).
46+
# Used for BOTH production and beta — the branch only selects the channel.
47+
STABLE="$(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)"
48+
if [ -z "$STABLE" ]; then
49+
echo "::error::No stable vX.Y.Z tag found. Push the release tag (e.g. v0.5.0) first."
50+
exit 1
51+
fi
52+
echo "Latest stable tag: $STABLE"
53+
54+
if [ "$BRANCH" = "main" ]; then
55+
if gh release view "$STABLE" >/dev/null 2>&1; then
56+
echo "Release $STABLE already exists — nothing to do."
57+
exit 0
58+
fi
59+
echo "Production release: $STABLE"
60+
gh release create "$STABLE" --title "$STABLE" --generate-notes --latest
61+
MAJOR="${STABLE%%.*}"
62+
git tag -f "$MAJOR" "$STABLE"
63+
git push origin -f "refs/tags/$MAJOR"
64+
echo "Moved major alias $MAJOR -> $STABLE"
65+
elif [[ "$BRANCH" == release/* ]]; then
66+
# Beta of the latest stable tag; auto-increment N from existing beta tags.
67+
LAST="$(git tag --list "${STABLE}-beta.*" | sed -E 's/.*-beta\.([0-9]+)$/\1/' | sort -n | tail -1)"
68+
N=$(( ${LAST:-0} + 1 ))
69+
TAG="${STABLE}-beta.${N}"
70+
echo "Beta release: $TAG (base $STABLE)"
71+
git tag "$TAG"
72+
git push origin "refs/tags/$TAG"
73+
gh release create "$TAG" --title "$TAG" --generate-notes --prerelease
74+
else
75+
echo "Branch '$BRANCH' is not configured for release — skipping."
76+
fi

action.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ inputs:
1313
description:
1414
'Provide Appcircle Personal API Token to authenticate Appcircle services.'
1515
required: true
16+
authEndpoint:
17+
description:
18+
'Optional: Authentication endpoint URL for self-hosted Appcircle
19+
installations. Defaults to the Appcircle cloud.'
20+
required: false
21+
default: 'https://auth.appcircle.io'
22+
apiEndpoint:
23+
description:
24+
'Optional: API endpoint URL for self-hosted Appcircle installations.
25+
Defaults to the Appcircle cloud.'
26+
required: false
27+
default: 'https://api.appcircle.io'
1628
profileName:
1729
description:
1830
'Enter the profile name of the Appcircle distribution profile. This name

dist/index.js

Lines changed: 68 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/api/authApi.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import axios from 'axios'
22

3-
const AUTH_HOSTNAME = 'https://auth.appcircle.io'
4-
5-
export async function getToken(pat: string): Promise<any> {
3+
export async function getToken(
4+
pat: string,
5+
authEndpoint = 'https://auth.appcircle.io'
6+
): Promise<any> {
67
const params = new URLSearchParams()
78
params.append('pat', pat)
89

10+
const authHostname = authEndpoint.replace(/\/+$/, '')
911
const response = await axios.post(
10-
`${AUTH_HOSTNAME}/auth/v1/token`,
12+
`${authHostname}/auth/v1/token`,
1113
params.toString(),
1214
{
1315
headers: {

src/api/uploadApi.ts

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,44 @@ import fs from 'fs'
33
import FormData from 'form-data'
44
import path from 'path';
55

6-
const API_HOSTNAME = 'https://api.appcircle.io'
6+
let apiHostname = 'https://api.appcircle.io'
77
export const appcircleApi = axios.create({
8-
baseURL: API_HOSTNAME.endsWith('/') ? API_HOSTNAME : `${API_HOSTNAME}/`
8+
baseURL: `${apiHostname}/`
99
})
1010

11+
export function setApiEndpoint(endpoint: string): void {
12+
if (!endpoint) return
13+
apiHostname = endpoint.replace(/\/+$/, '')
14+
appcircleApi.defaults.baseURL = `${apiHostname}/`
15+
}
16+
17+
async function uploadWithRetry(
18+
doUpload: () => Promise<any>,
19+
maxRetries = 5
20+
): Promise<any> {
21+
let attempt = 0
22+
let delay = 1000
23+
while (true) {
24+
try {
25+
return await doUpload()
26+
} catch (error: any) {
27+
const status = error?.response?.status
28+
const retryable =
29+
status === 503 ||
30+
error?.code === 'ECONNRESET' ||
31+
(typeof error?.message === 'string' &&
32+
error.message.includes('socket hang up'))
33+
if (!retryable || attempt >= maxRetries) {
34+
throw error
35+
}
36+
attempt++
37+
const jitter = Math.floor(Math.random() * 300)
38+
await new Promise(resolve => setTimeout(resolve, delay + jitter))
39+
delay *= 2
40+
}
41+
}
42+
}
43+
1144
export class UploadServiceHeaders {
1245
static token = ''
1346

@@ -37,6 +70,10 @@ export async function uploadArtifact(options: {
3770
const uploadInfoResponse = await appcircleApi.get<{
3871
fileId: string;
3972
uploadUrl: string;
73+
configuration?: {
74+
httpMethod: string;
75+
signParameters: Record<string, string>;
76+
};
4077
}>(
4178
`distribution/v1/profiles/${options.distProfileId}/app-versions`,
4279
{
@@ -53,18 +90,31 @@ export async function uploadArtifact(options: {
5390
}
5491
console.log("File upload information retrieved successfully with status code:", uploadInfoResponse.status)
5592

56-
const { fileId, uploadUrl } = uploadInfoResponse.data;
57-
58-
const fileContent = fs.readFileSync(filePath);
93+
const { fileId, uploadUrl, configuration } = uploadInfoResponse.data;
94+
const httpMethod = configuration?.httpMethod?.toUpperCase() ?? 'PUT'
95+
const signParameters = configuration?.signParameters ?? {}
5996

6097
console.log("Uploading file to Appcircle...")
61-
const uploadResponse = await axios.put(uploadUrl, fileContent, {
62-
headers: {
63-
'Content-Type': 'application/octet-stream'
64-
},
65-
maxContentLength: Infinity,
66-
maxBodyLength: Infinity
67-
});
98+
const uploadResponse = await uploadWithRetry(() => {
99+
if (httpMethod === 'POST') {
100+
// Presigned POST (e.g. MinIO on self-hosted): sign params first, file LAST.
101+
const form = new FormData()
102+
for (const [key, value] of Object.entries(signParameters)) {
103+
form.append(key, value)
104+
}
105+
form.append('file', fs.createReadStream(filePath), fileName)
106+
return axios.post(uploadUrl, form, {
107+
maxContentLength: Infinity,
108+
maxBodyLength: Infinity,
109+
headers: { ...form.getHeaders() }
110+
})
111+
}
112+
return axios.put(uploadUrl, fs.readFileSync(filePath), {
113+
headers: { 'Content-Type': 'application/octet-stream' },
114+
maxContentLength: Infinity,
115+
maxBodyLength: Infinity
116+
})
117+
})
68118
if (uploadResponse.status < 200 || uploadResponse.status >= 300) {
69119
throw new Error("Failed to upload file with status code: " + uploadResponse.status)
70120
}
@@ -158,7 +208,7 @@ export async function checkTaskStatus(
158208
taskId: string,
159209
currentAttempt = 0
160210
) {
161-
const response = await fetch(`${API_HOSTNAME}/task/v1/tasks/${taskId}`, {
211+
const response = await fetch(`${apiHostname}/task/v1/tasks/${taskId}`, {
162212
method: 'GET',
163213
headers: {
164214
'Content-Type': 'application/json',

0 commit comments

Comments
 (0)