If you already have an app registration, enter the details below.
@@ -205,15 +218,69 @@ function setTenantMode(section, isMulti) {
if (setupState.isEasyAuthConfigured) {
showBanner('Authentication is already configured. Redirecting...');
setTimeout(() => window.location.href = '/', 2000);
+ return;
}
if (!setupState.isRunningInAppService || !setupState.hasManagedIdentity) {
showBanner('Warning: No managed identity detected. ARM self-configuration may fail. Use manual setup instead.');
}
+
+ // Handle user table status
+ const us = setupState.usersStatus;
+ if (!us || !us.connected) {
+ // Connection error — disable user section
+ document.getElementById('btn-seed').disabled = true;
+ showStatus('seed-status', 'Cannot connect to storage: ' + (us?.error || 'Unknown error'), 'error');
+ } else if (us.hasUsers) {
+ // Users already exist — skip to auth setup
+ document.getElementById('user-section').classList.add('disabled-section');
+ showStatus('seed-status', 'Users already exist in the table. Proceed to authentication setup below.', 'success');
+ enableAuthSections();
+ } else {
+ // No users — enable the seed form, keep auth disabled
+ document.getElementById('btn-seed').disabled = false;
+ }
} catch (e) {
console.error('Failed to load status', e);
}
})();
+ function enableAuthSections() {
+ document.getElementById('auto-section').classList.remove('disabled-section');
+ document.getElementById('manual-section').classList.remove('disabled-section');
+ }
+
+ async function seedFirstUser() {
+ const upn = document.getElementById('seed-upn').value.trim();
+ if (!upn) {
+ showStatus('seed-status', 'Please enter a valid email address.', 'error');
+ return;
+ }
+
+ const btn = document.getElementById('btn-seed');
+ btn.disabled = true;
+ btn.textContent = 'Adding user...';
+ showStatus('seed-status', 'Adding superadmin user...', 'info');
+
+ try {
+ const res = await fetch('/api/setup/seed-user', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ upn })
+ });
+
+ const data = await res.json();
+ if (!res.ok || !data.success) throw new Error(data.message || 'Failed to add user');
+
+ showStatus('seed-status', data.message, 'success');
+ document.getElementById('user-section').classList.add('disabled-section');
+ enableAuthSections();
+ } catch (e) {
+ showStatus('seed-status', 'Error: ' + e.message, 'error');
+ btn.disabled = false;
+ btn.textContent = 'Add Superadmin';
+ }
+ }
+
function showBanner(msg) {
const el = document.getElementById('status-banner');
el.textContent = msg;
@@ -378,9 +445,11 @@ async function submitManual() {
}
function showRestartScreen() {
// Hide setup sections, show restart polling UI
+ document.getElementById('user-section').classList.add('hidden');
document.getElementById('auto-section').classList.add('hidden');
document.getElementById('manual-section').classList.add('hidden');
- document.querySelector('.divider').classList.add('hidden');
+ document.getElementById('divider-user').classList.add('hidden');
+ document.getElementById('divider-auth').classList.add('hidden');
document.querySelector('.subtitle').textContent = '';
document.getElementById('page-title').textContent = 'Restarting...';
diff --git a/Services/SetupService.cs b/Services/SetupService.cs
index 220b576..3d3b1c5 100644
--- a/Services/SetupService.cs
+++ b/Services/SetupService.cs
@@ -2,6 +2,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using Azure.Data.Tables;
namespace Craft.Services;
@@ -584,16 +585,120 @@ public async Task ConfigureManual(
await ConfigureAppServiceAuth(appId, clientSecret, tenantId, multiTenant, ct);
}
+ // ── First User Seeding ──
+
+ ///
+ /// Resolves the storage connection string for the allowedUsers table.
+ /// Same logic as AuthService — uses Auth.UserStorageConnection if set,
+ /// falls back to AzureWebJobsStorage, then dev storage.
+ ///
+ private string StorageConnectionString =>
+ (!string.IsNullOrEmpty(_settings.Auth.UserStorageConnection)
+ ? _settings.Auth.UserStorageConnection
+ : Environment.GetEnvironmentVariable("AzureWebJobsStorage"))
+ ?? "UseDevelopmentStorage=true";
+
+ ///
+ /// Resolves the user table name with the same sanitization as AuthService.
+ ///
+ private string ResolveUserTableName()
+ {
+ var raw = _settings.Auth.UserTableName;
+ var sanitized = new string(raw.Where(char.IsLetterOrDigit).ToArray());
+ if (sanitized.Length > 63) sanitized = sanitized[..63];
+ if (sanitized.Length < 3) sanitized = "allowedUsers";
+ return sanitized;
+ }
+
+ ///
+ /// Checks the allowedUsers table status: whether it's reachable and whether
+ /// it already contains any users.
+ ///
+ public async Task
CheckAllowedUsersStatus(CancellationToken ct = default)
+ {
+ try
+ {
+ var tableName = ResolveUserTableName();
+ var client = new TableClient(StorageConnectionString, tableName);
+ await client.CreateIfNotExistsAsync(cancellationToken: ct);
+
+ var count = 0;
+ await foreach (var entity in client.QueryAsync(cancellationToken: ct))
+ {
+ if (!entity.RowKey.StartsWith("_"))
+ {
+ count++;
+ if (count > 0) break; // We only need to know if any exist
+ }
+ }
+
+ return new AllowedUsersStatus
+ {
+ Connected = true,
+ HasUsers = count > 0
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "[Setup] Failed to check allowedUsers table");
+ return new AllowedUsersStatus
+ {
+ Connected = false,
+ HasUsers = false,
+ Error = ex.Message
+ };
+ }
+ }
+
+ ///
+ /// Seeds the first superadmin user into the allowedUsers table.
+ /// Only works when the table is empty — refuses if users already exist.
+ /// Uses the same entity schema as CIPP-API's Invoke-ExecCIPPUsers.
+ ///
+ public async Task SeedFirstUser(string upn, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(upn))
+ throw new ArgumentException("UPN (email) is required.");
+
+ upn = upn.Trim().ToLower();
+
+ var tableName = ResolveUserTableName();
+ var client = new TableClient(StorageConnectionString, tableName);
+ await client.CreateIfNotExistsAsync(cancellationToken: ct);
+
+ // Guard: refuse if the table already has users
+ await foreach (var entity in client.QueryAsync(cancellationToken: ct))
+ {
+ if (!entity.RowKey.StartsWith("_"))
+ throw new InvalidOperationException("The allowed users table already contains users. First-user seeding is only available on an empty table.");
+ }
+
+ var roles = new[] { "superadmin" };
+ var rolesJson = JsonSerializer.Serialize(roles);
+
+ var userEntity = new TableEntity("User", upn)
+ {
+ ["Roles"] = rolesJson,
+ ["ManualRoles"] = rolesJson,
+ ["AutoRoles"] = "[]",
+ ["Source"] = "Manual"
+ };
+
+ await client.UpsertEntityAsync(userEntity, TableUpdateMode.Replace, ct);
+ _logger.LogInformation("[Setup] Seeded first superadmin user: {Upn}", upn);
+ }
+
// ── Status ──
///
/// Returns setup status information.
///
- public SetupStatus GetStatus()
+ public async Task GetStatus(CancellationToken ct = default)
{
var isConfigured = IsEasyAuthConfigured();
var siteName = Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME");
var hasManagedIdentity = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT"));
+ var usersStatus = await CheckAllowedUsersStatus(ct);
return new SetupStatus
{
@@ -602,7 +707,8 @@ public SetupStatus GetStatus()
HasManagedIdentity = hasManagedIdentity,
AppName = _settings.Name,
AuthAppDisplayName = ResolveAuthAppDisplayName(),
- BootstrapClientId = _settings.Setup.BootstrapClientId
+ BootstrapClientId = _settings.Setup.BootstrapClientId,
+ UsersStatus = usersStatus
};
}
@@ -836,6 +942,14 @@ public class SetupStatus
public string AppName { get; set; } = "";
public string AuthAppDisplayName { get; set; } = "";
public string BootstrapClientId { get; set; } = "";
+ public AllowedUsersStatus UsersStatus { get; set; } = new();
+ }
+
+ public class AllowedUsersStatus
+ {
+ public bool Connected { get; set; }
+ public bool HasUsers { get; set; }
+ public string? Error { get; set; }
}
public class DeviceCodeResponse
From 0bc032f00d5c20bcde880f54e72c385f88274946 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 29 May 2026 08:11:01 +0800
Subject: [PATCH 2/4] auth tweaks
---
Services/AppLifecycleBridge.cs | 2 +-
Services/CraftSettings.cs | 9 ---------
Services/Program.cs | 17 +++++------------
3 files changed, 6 insertions(+), 22 deletions(-)
diff --git a/Services/AppLifecycleBridge.cs b/Services/AppLifecycleBridge.cs
index 6b5ec4e..c77e7eb 100644
--- a/Services/AppLifecycleBridge.cs
+++ b/Services/AppLifecycleBridge.cs
@@ -57,7 +57,7 @@ public static void RequestSetupMode(string reason = "Setup mode requested by app
///
/// Returns true if the child app has explicitly requested setup mode.
- /// Used by the setup middleware when AutoActivate is false.
+ /// Used by the setup middleware to determine whether to activate the setup wizard.
///
public static bool IsSetupModeRequested() => s_setupModeRequested;
}
diff --git a/Services/CraftSettings.cs b/Services/CraftSettings.cs
index 3251a0d..aef49d5 100644
--- a/Services/CraftSettings.cs
+++ b/Services/CraftSettings.cs
@@ -517,15 +517,6 @@ public class SetupSettings
///
public bool Enabled { get; set; } = false;
- ///
- /// When true, the setup wizard activates automatically if EasyAuth is not configured.
- /// When false, the child app must explicitly call
- /// [Craft.Services.AppLifecycleBridge]::RequestSetupMode() to activate setup mode.
- /// This lets the child app decide when setup is appropriate (e.g. after checking
- /// for existing credentials that can be migrated automatically).
- ///
- public bool AutoActivate { get; set; } = true;
-
///
/// Public client ID used for the PKCE login popup during automated setup.
/// Defaults to Microsoft's Azure PowerShell first-party app which supports
diff --git a/Services/Program.cs b/Services/Program.cs
index c1d0efd..da1bd4c 100644
--- a/Services/Program.cs
+++ b/Services/Program.cs
@@ -278,15 +278,12 @@ void RunInitialization()
app.UseResponseCompression();
// Setup mode middleware: when Setup.Enabled, register setup route guards.
-// If AutoActivate is true, setup mode activates automatically when EasyAuth is not configured.
-// If AutoActivate is false, the child app must call AppLifecycleBridge.RequestSetupMode()
-// to explicitly enable the setup wizard (e.g. after checking for existing credentials).
+// The child app must call AppLifecycleBridge.RequestSetupMode() to activate the
+// setup wizard (e.g. after determining it cannot auto-configure from existing credentials).
var setupService = app.Services.GetRequiredService();
if (CraftSettings.Setup.Enabled)
{
- var autoActivate = CraftSettings.Setup.AutoActivate;
-
app.Use(async (context, next) =>
{
if (SetupService.IsEasyAuthConfigured())
@@ -314,10 +311,8 @@ void RunInitialization()
return;
}
- // EasyAuth NOT configured — check whether setup mode should be active.
- // AutoActivate: always active. Otherwise: only after child app calls RequestSetupMode().
- var setupActive = autoActivate || AppLifecycleBridge.IsSetupModeRequested();
- if (!setupActive)
+ // EasyAuth NOT configured — setup mode only active after child app calls RequestSetupMode().
+ if (!AppLifecycleBridge.IsSetupModeRequested())
{
// Setup not yet requested by child app — let requests through normally
// (the startup loading middleware will handle the "pool not ready" case)
@@ -368,10 +363,8 @@ void RunInitialization()
context.Response.Redirect("/setup");
});
- logger.LogInformation("[Setup] Setup mode enabled (AutoActivate={AutoActivate}) — {Status}",
- autoActivate,
+ logger.LogInformation("[Setup] Setup mode enabled — {Status}",
SetupService.IsEasyAuthConfigured() ? "EasyAuth already configured, setup endpoints disabled"
- : autoActivate ? "awaiting configuration at /setup"
: "waiting for child app to call RequestSetupMode()");
}
From d5899b947e1fad1e918bdb139835f4ccbd586e80 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 29 May 2026 08:59:15 +0800
Subject: [PATCH 3/4] setup page tweaks
---
Services/AppLifecycleBridge.cs | 24 ++++++++++++++++
Services/CraftSettings.cs | 14 ++++++++++
Services/Program.cs | 8 ++++++
Services/SetupPages.cs | 17 ++++++++++++
Services/SetupService.cs | 51 ++++++++++++++++++++++++++++++++--
appsettings.json | 6 +++-
6 files changed, 116 insertions(+), 4 deletions(-)
diff --git a/Services/AppLifecycleBridge.cs b/Services/AppLifecycleBridge.cs
index c77e7eb..6935472 100644
--- a/Services/AppLifecycleBridge.cs
+++ b/Services/AppLifecycleBridge.cs
@@ -43,6 +43,8 @@ public static bool IsEasyAuthConfigured()
// --- Setup mode gating ---
private static volatile bool s_setupModeRequested;
+ private static volatile bool s_setupCompleted;
+ private static string? s_setupCompletedReason;
///
/// Explicitly enables the Craft setup wizard. Call this from the child app
@@ -60,4 +62,26 @@ public static void RequestSetupMode(string reason = "Setup mode requested by app
/// Used by the setup middleware to determine whether to activate the setup wizard.
///
public static bool IsSetupModeRequested() => s_setupModeRequested;
+
+ ///
+ /// Marks setup as completed — credentials have been applied and the app is
+ /// pending restart. Prevents duplicate credential submissions and lets all
+ /// setup page instances detect completion via status polling.
+ ///
+ public static void MarkSetupCompleted(string reason = "Setup credentials applied")
+ {
+ s_setupCompleted = true;
+ s_setupCompletedReason = reason;
+ s_logger?.LogInformation("[Lifecycle] Setup marked as completed: {Reason}", reason);
+ }
+
+ ///
+ /// Returns true if setup credentials have already been applied this session.
+ ///
+ public static bool IsSetupCompleted() => s_setupCompleted;
+
+ ///
+ /// Returns the reason setup was completed, or null if not yet completed.
+ ///
+ public static string? GetSetupCompletedReason() => s_setupCompletedReason;
}
diff --git a/Services/CraftSettings.cs b/Services/CraftSettings.cs
index aef49d5..53e3231 100644
--- a/Services/CraftSettings.cs
+++ b/Services/CraftSettings.cs
@@ -573,6 +573,20 @@ public class SetupSettings
/// The tenant from the setup flow is always included automatically.
///
public List AllowedTenants { get; set; } = [];
+
+ ///
+ /// When set, the EasyAuth client secret is stored in Azure Key Vault instead of
+ /// directly in the app setting. The app setting AUTH_SECRET is then written as a
+ /// Key Vault reference (@Microsoft.KeyVault(SecretUri=...)).
+ ///
+ /// Value is the Key Vault name (e.g. "my-vault" → https://my-vault.vault.azure.net).
+ /// If set to the literal string "auto", the site name (WEBSITE_SITE_NAME) is used
+ /// as the vault name.
+ ///
+ /// The managed identity must have Secret Set permission on the vault.
+ /// When empty (default), the secret is stored directly in the app setting.
+ ///
+ public string KeyVaultName { get; set; } = "";
}
///
diff --git a/Services/Program.cs b/Services/Program.cs
index da1bd4c..b72a3c2 100644
--- a/Services/Program.cs
+++ b/Services/Program.cs
@@ -969,6 +969,9 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
app.MapPost("/api/setup/configure", async (HttpContext context) =>
{
+ if (AppLifecycleBridge.IsSetupCompleted())
+ return Results.Json(new { success = false, message = "Setup already completed. The app is pending restart." }, statusCode: 409);
+
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
using var doc = System.Text.Json.JsonDocument.Parse(body);
@@ -980,11 +983,15 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
var multiTenant = root.TryGetProperty("multiTenant", out var mt) && mt.GetBoolean();
await setupService.ConfigureAppServiceAuth(appId, clientSecret, tenantId, multiTenant);
+ AppLifecycleBridge.MarkSetupCompleted("EasyAuth configured via automated setup");
return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." });
});
app.MapPost("/api/setup/manual", async (HttpContext context) =>
{
+ if (AppLifecycleBridge.IsSetupCompleted())
+ return Results.Json(new { success = false, message = "Setup already completed. The app is pending restart." }, statusCode: 409);
+
using var reader = new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();
using var doc = System.Text.Json.JsonDocument.Parse(body);
@@ -996,6 +1003,7 @@ await context.Response.WriteAsync(System.Text.Json.JsonSerializer.Serialize(new
var multiTenant = root.TryGetProperty("multiTenant", out var mt2) && mt2.GetBoolean();
await setupService.ConfigureManual(appId, clientSecret, tenantId, multiTenant);
+ AppLifecycleBridge.MarkSetupCompleted("EasyAuth configured via manual setup");
return Results.Json(new { success = true, message = "App Service auth configured. The app will restart to apply changes." });
});
diff --git a/Services/SetupPages.cs b/Services/SetupPages.cs
index 34f725b..dccca98 100644
--- a/Services/SetupPages.cs
+++ b/Services/SetupPages.cs
@@ -244,6 +244,21 @@ function setTenantMode(section, isMulti) {
}
})();
+ // Background poll — detect setup completion from any session
+ let statusPollTimer = setInterval(async () => {
+ try {
+ const res = await fetch('/api/setup/status', { cache: 'no-store' });
+ if (!res.ok) return;
+ const status = await res.json();
+ if (status.isSetupCompleted || status.isEasyAuthConfigured) {
+ clearInterval(statusPollTimer);
+ showRestartScreen();
+ }
+ } catch (e) {
+ // Setup endpoint may be unavailable during restart — ignore
+ }
+ }, 5000);
+
function enableAuthSections() {
document.getElementById('auto-section').classList.remove('disabled-section');
document.getElementById('manual-section').classList.remove('disabled-section');
@@ -444,6 +459,8 @@ async function submitManual() {
}
}
function showRestartScreen() {
+ // Stop background status polling — restart screen has its own polling
+ clearInterval(statusPollTimer);
// Hide setup sections, show restart polling UI
document.getElementById('user-section').classList.add('hidden');
document.getElementById('auto-section').classList.add('hidden');
diff --git a/Services/SetupService.cs b/Services/SetupService.cs
index 3d3b1c5..d10f9e0 100644
--- a/Services/SetupService.cs
+++ b/Services/SetupService.cs
@@ -479,8 +479,30 @@ public async Task ConfigureAppServiceAuth(
}
}
- // 2. Add the client secret setting (referenced by authsettingsV2 clientSecretSettingName)
- mergedSettings["AUTH_SECRET"] = clientSecret;
+ // 2. Store the client secret — either directly or via Key Vault reference
+ var kvName = _settings.Setup.KeyVaultName;
+ if (!string.IsNullOrEmpty(kvName))
+ {
+ if (kvName.Equals("auto", StringComparison.OrdinalIgnoreCase))
+ kvName = siteName;
+
+ // Store secret in Key Vault via REST API
+ var vaultToken = await GetManagedIdentityToken("https://vault.azure.net", ct)
+ ?? throw new InvalidOperationException("Cannot get Key Vault token — ensure the managed identity has Secret Set permission on the vault");
+
+ var kvSecretUrl = $"https://{kvName}.vault.azure.net/secrets/AUTH-SECRET?api-version=7.4";
+ var kvBody = new { value = clientSecret };
+ await KeyVaultRequest(HttpMethod.Put, kvSecretUrl, vaultToken, kvBody, ct);
+
+ // Set app setting as a KV reference
+ mergedSettings["AUTH_SECRET"] = $"@Microsoft.KeyVault(VaultName={kvName};SecretName=AUTH-SECRET)";
+ _logger.LogInformation("[Setup] Client secret stored in Key Vault '{VaultName}', app setting set as KV reference", kvName);
+ }
+ else
+ {
+ // Store secret directly in app setting (default)
+ mergedSettings["AUTH_SECRET"] = clientSecret;
+ }
// Determine effective allowed tenants (always include the setup tenant)
bool useCommonIssuer = multiTenant;
@@ -496,7 +518,7 @@ await ArmRequest(HttpMethod.Put,
$"{baseUri}/config/appsettings?api-version=2024-11-01",
managementToken, settingsBody, ct);
- _logger.LogInformation("[Setup] App settings updated (AUTH_SECRET set, WEBSITE_AUTH_AAD_ALLOWED_TENANTS removed)");
+ _logger.LogInformation("[Setup] App settings updated (WEBSITE_AUTH_AAD_ALLOWED_TENANTS removed)");
// 4. Configure authsettingsV2
var globalValidation = new Dictionary
@@ -703,6 +725,8 @@ public async Task GetStatus(CancellationToken ct = default)
return new SetupStatus
{
IsEasyAuthConfigured = isConfigured,
+ IsSetupCompleted = AppLifecycleBridge.IsSetupCompleted(),
+ SetupCompletedReason = AppLifecycleBridge.GetSetupCompletedReason(),
IsRunningInAppService = !string.IsNullOrEmpty(siteName),
HasManagedIdentity = hasManagedIdentity,
AppName = _settings.Name,
@@ -917,6 +941,25 @@ private async Task ArmRequest(
return doc.RootElement.Clone();
}
+ private async Task KeyVaultRequest(
+ HttpMethod method, string url, string accessToken, object body, CancellationToken ct)
+ {
+ using var request = new HttpRequestMessage(method, url);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
+
+ var json = JsonSerializer.Serialize(body);
+ request.Content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ var response = await s_httpClient.SendAsync(request, ct);
+ if (!response.IsSuccessStatusCode)
+ {
+ var responseBody = await response.Content.ReadAsStringAsync(ct);
+ _logger.LogError("[Setup] Key Vault {Method} {Url} failed: {Status} {Body}",
+ method, url, response.StatusCode, responseBody);
+ throw new HttpRequestException($"Key Vault {method} {url} failed: {response.StatusCode}");
+ }
+ }
+
// ── Result Models ──
public class TokenExchangeResult
@@ -937,6 +980,8 @@ public class AppRegistrationResult
public class SetupStatus
{
public bool IsEasyAuthConfigured { get; set; }
+ public bool IsSetupCompleted { get; set; }
+ public string? SetupCompletedReason { get; set; }
public bool IsRunningInAppService { get; set; }
public bool HasManagedIdentity { get; set; }
public string AppName { get; set; } = "";
diff --git a/appsettings.json b/appsettings.json
index 249c363..a62c97f 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -114,7 +114,11 @@
// "AllowedAudiences": [],
// // Tenant IDs allowed to auth. Empty = setup tenant only (single-tenant issuer).
// // Multiple entries → issuer becomes "common" + WEBSITE_AUTH_AAD_ALLOWED_TENANTS is set.
- // "AllowedTenants": []
+ // "AllowedTenants": [],
+ // // Store EasyAuth client secret in Key Vault instead of app settings.
+ // // "auto" = use site name as vault name. Or set explicit vault name.
+ // // Empty (default) = store secret directly in AUTH_SECRET app setting.
+ // "KeyVaultName": ""
// },
"Scheduler": {
From a4cb5771a1521378c523a3a3c19cdae03b0b85e7 Mon Sep 17 00:00:00 2001
From: Zacgoose <107489668+Zacgoose@users.noreply.github.com>
Date: Fri, 29 May 2026 09:04:33 +0800
Subject: [PATCH 4/4] workflow
---
.github/workflows/dev-container.yml | 5 +++++
.github/workflows/release-container.yml | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/.github/workflows/dev-container.yml b/.github/workflows/dev-container.yml
index 76dce37..895c991 100644
--- a/.github/workflows/dev-container.yml
+++ b/.github/workflows/dev-container.yml
@@ -32,12 +32,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: build/Dockerfile
push: true
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
build-args: |
APP_VERSION=dev
COMMIT_SHA=${{ github.sha }}
diff --git a/.github/workflows/release-container.yml b/.github/workflows/release-container.yml
index c2d1b7e..ef62776 100644
--- a/.github/workflows/release-container.yml
+++ b/.github/workflows/release-container.yml
@@ -36,12 +36,17 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: build/Dockerfile.release
push: true
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
build-args: |
APP_VERSION=${{ steps.version.outputs.app_version }}
COMMIT_SHA=${{ github.sha }}